feat(sharing): per-folder / per-project collaborators API - #111
Conversation
Folders and projects had no way to add a collaborator: `doc_acl` rows were only
ever written from routes/docs.ts and routes/org.ts, and routes/folders.ts had no
sharing endpoints at all. This adds the missing CRUD, and nothing else.
GET /api/{folders|projects}/:id/collaborators
POST /api/{folders|projects}/:id/collaborators
PATCH /api/{folders|projects}/:id/collaborators/:userId
DELETE /api/{folders|projects}/:id/collaborators/:userId
Purely additive — no schema change and no change to how permissions resolve. It
writes the SAME `doc_acl` rows the org access matrix already writes, for resource
types `doc_acl` has always modelled ('folder' | 'project'), so
app_effective_permission() precedence and the canAccess() folder/project -> doc
inheritance already do the rest: a grant here automatically covers everything
inside the folder/project.
Authorization: managing shares requires `admin` ON THE RESOURCE via canAccess(),
which falls through to the workspace role. Unknown ids and ids in another tenant
both 404 (existence is checked through RLS, so this can't probe foreign ids).
Every grant/update/revoke is written to iam_audit_log.
Scope: the grantee must already be a workspace member — POST returns
`not_a_workspace_member` otherwise. Inviting an outside email needs a pending
grant that materialises on signup plus guest-visibility work, which land separately.
Validation lives in lib/collaborators/grants.ts rather than the route so it is
importable without the db/config graph (same split as lib/community/handle.ts),
which is what makes it unit-testable. A test asserts 'comment' does NOT validate
yet, so the future comment tier can't be half-landed by accident.
Signed-off-by: Jason-jo17 <jason@theboringpeople.in>
|
Heads-up on the While building the phase-4 UI I found that projects already ship an "Access · {project}" modal in So after this PR a project has two valid ways to grant access:
I checked whether the Folders are the unambiguous gap — Options, your call:
I've deliberately kept the phase-4 UI (#197) to folders only so it doesn't put a rival dialog next to the working project Access modal. |
2798e33
into
nbkdoesntknowcoding:main
What & why
A folder or project can't be shared with a specific person today. The permission engine already supports it — only the API surface was missing:
doc_aclrows are only ever written fromroutes/docs.tsandroutes/org.ts, androutes/folders.tshas no sharing endpoints at all (nor doesroutes/projects.ts).This adds the missing CRUD — and nothing else:
Deliberately additive — no schema change, and no change to how permissions resolve. It writes the same
doc_aclrows the org access matrix already writes, for resource typesdoc_aclhas always modelled ('folder' | 'project').app_effective_permission()precedence (explicit deny wins, then best positive) and thecanAccess()folder/project → doc inheritance already do the rest — so a grant on a folder automatically covers every doc inside it, with no new resolution logic.Authorization. Managing shares requires
adminon the resource viacanAccess(), which falls through to the workspace role — owners/admins manage sharing without an explicit grant on every resource. Unknown ids and ids belonging to another tenant both404(existence is checked through RLS, so this can't be used to probe for foreign ids). Every grant/update/revoke is written toiam_audit_log.Scope (intentional). The grantee must already be a workspace member;
POSTreturnsnot_a_workspace_memberotherwise. Inviting an outside email needs a pending grant that materialises on signup plus a restricted-visibility guest role — kept out of this PR because it changes a security default and deserves its own decision.Validation lives in
lib/collaborators/grants.tsrather than in the route so it's importable without the db/config graph (same split aslib/community/handle.ts) — that's what makes it unit-testable. The permission vocabulary is pinned todoc_acl's, and a test asserts a'comment'tier does not validate yet, so a future tier can't get half-landed by accident.Linked issue
Refs #112 — which also asks the core vs enterprise question: CONTRIBUTING lists org-IAM-SSO as enterprise, but
doc_acl,lib/iam.ts,folders.tsandprojects.tsare all in this repo, so resource-level ACL reads as core while org-wide IAM is the enterprise part. Built in core on that basis — happy to relocate, it's one route file, one lib file and one registration line. Please treat #112 as the decision point before merging.Test evidence
pnpm --filter @boppl/api run typecheck— clean.eslint --max-warnings 0onroutes/collaborators.ts,lib/collaborators/grants.ts,grants.test.ts,server.ts— clean.verify-core-only— CLEAN (core compiles with the enterprise modules absent).doc_acl; explicit deny stays grantable (it's not the same as revoking);'comment'rejected; non-uuid principal and non-ISO expiry rejected; empty patch rejected; null expiry = permanent.mainbaseline: identical pre-existing failures, +12 passing on top — no regressions.Not run: a live end-to-end against a real Postgres with RLS enabled. Worth exercising grant → inherited doc access → revoke on a real instance before merge, since the inheritance path is the whole payoff of this change.
Checklist
org.tsgrant-writing convention).pnpm test, core-boundary) for what I touched.git commit -s).No new dependencies (so the license-policy gate is unaffected) and no migration.