digidem / digidem/comapeo-cloud-app
feat: add project-scoped invitation codes for remote archives
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 0
- Forks
- 0
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 29
Description
Status
Implementation-ready. This is a coordinated two-repository change: digidem/comapeo-cloud must add server-enforced project-scoped REST credentials first, then digidem/comapeo-cloud-app consumes that capability and exposes project-scoped invitation generation/onboarding.
The Cloud App PR should close this issue only after the required comapeo-cloud server contract is merged and available for integration testing.
Goal
Allow a remote archive invitation to grant access to one specific project without exposing or granting REST access to any other project on that archive.
Project scope is an authorization boundary, not a UI filter. A recipient of a project-scoped invitation must be able to use the existing Cloud App onboarding/sync flow while comapeo-cloud enforces the scope independently of the client.
Current architecture and root constraint
The current invite system encrypts an existing archive credential; it does not create authorization scope:
SettingsScreenaccepts a remote archive URL and bearer token.createEncryptedInvite()sends{ url, token, ttlHours? }to the first-party/api/invites/encryptPages Function.- The function AES-GCM encrypts
{ url, token, exp }into an opaquev1.*code. /invite?code=...redeems it through/api/invites/decrypt.InviteScreenreceives the archive URL and bearer token, validates/healthcheck+GET /projects, persists the credential, and runssyncRemoteArchive().syncRemoteArchive()synchronizes every project returned byGET /projects.
Relevant Cloud App surfaces:
src/lib/schemas/invite.tssrc/lib/invite-crypto.tsfunctions/api/invites/encrypt.tsfunctions/api/invites/decrypt.tssrc/lib/api-client.tssrc/screens/SettingsScreen.tsxsrc/screens/InviteScreen.tsxsrc/stores/auth-store.tssrc/lib/local-repositories.tssrc/lib/remote-archive.tssrc/lib/sync.ts
Current comapeo-cloud main uses one SERVER_BEARER_TOKEN for all bearer-protected REST access. GET /projects returns all projects visible to that server token, and the same token protects project resources. Therefore adding projectId only to the encrypted Cloud App invitation would not provide isolation: the recipient would still hold an archive-wide credential and could bypass client filtering.
projectPublicId is already the stable canonical project identifier. It is used by comapeo-cloud routes and CoMapeo replication. No new project identity is required.
Decisions
1. Scope the server credential, not just the invite envelope
comapeo-cloud must issue a bearer credential whose authority is limited to one projectPublicId.
The Cloud App treats that credential as opaque. Scope is always enforced by comapeo-cloud; client-side scope metadata is for persistence/UX only and is never trusted as authorization.
2. Keep the existing archive-wide credential fully backward compatible
SERVER_BEARER_TOKEN remains the archive-wide/master credential with its current REST permissions.
Existing archive-wide invite codes continue to work. No existing server deployment should fail to start because project-scoped invitations are not configured.
3. Add a stateless signed project-access token to comapeo-cloud
Add optional server configuration:
PROJECT_ACCESS_TOKEN_SECRET- 32 random bytes encoded as base64 (validate length after decoding)
- when absent, normal archive operation and
SERVER_BEARER_TOKENbehavior are unchanged; only project-token minting is unavailable
Use a versioned opaque bearer token format such as:
cpat1.<base64url-payload>.<base64url-hmac>
Payload must contain at minimum:
{
"v": 1,
"projectId": "<projectPublicId>",
"nonce": "<cryptographically random value>"
}
Sign the encoded payload with HMAC-SHA-256 using PROJECT_ACCESS_TOKEN_SECRET. Verify signature in constant time. The app must not parse this token.
The scoped credential itself has no separate expiry in this issue. The existing encrypted invite's 24-hour exp remains the invitation redemption window. Rotating PROJECT_ACCESS_TOKEN_SECRET revokes all issued project tokens. Fine-grained token revocation/management is a separate feature.
4. Add one server endpoint to mint scoped credentials
Add to comapeo-cloud:
POST /projects/:projectPublicId/accessTokens
Contract:
- requires the archive-wide
SERVER_BEARER_TOKEN - a project-scoped token cannot mint another token through this endpoint
- project must exist before issuance
- body: none required
- response:
{
"data": {
"token": "cpat1....",
"projectId": "<projectPublicId>"
}
}
If PROJECT_ACCESS_TOKEN_SECRET is not configured, return a stable error such as 501 PROJECT_ACCESS_TOKENS_UNAVAILABLE. Cloud App must surface this as “This archive does not support project-scoped invitations” and must never fall back to sharing the archive-wide token.
5. Resolve bearer auth to an authorization principal
Refactor the current verifyBearerAuth() concept so a valid Authorization header resolves to one of:
archive principal -> SERVER_BEARER_TOKEN
project principal -> signed project token + projectPublicId
Invalid/malformed/tampered tokens return 401.
Authorization rules:
| Route / operation | Archive credential | Project credential |
|---|---|---|
GET /projects |
all projects | only the scoped project |
| Existing bearer-protected project datatype list/detail routes | allowed | allowed only when path project matches scope |
| Project icons | allowed | allowed only when path project matches scope |
| Project attachments | allowed | allowed only when path project matches scope |
GET /projects/:id/remoteDetectionAlerts |
allowed | allowed only when path project matches scope |
POST /projects/:id/remoteDetectionAlerts |
allowed | allowed only when path project matches scope |
POST /projects/:id/accessTokens |
allowed | denied |
There is currently no GET /projects/:id route on comapeo-cloud main. Do not add one solely for this feature. Cloud App already treats its optional project-detail request as non-critical when unsupported.
For a project-scoped credential requesting a different project ID, return the same not-found behavior used for a nonexistent project. Do not return a distinguishable “project exists but you lack access” response. GET /projects must never include out-of-scope project IDs/names.
Permissions within the authorized project stay the same as the existing bearer credential. This issue adds project isolation, not read/write roles; in particular the existing remote-detection-alert write remains available for the authorized project.
6. Do not change CoMapeo project replication for this feature
/sync/:projectPublicId is not part of the REST bearer-token model. comapeo-core constructs that WebSocket URL from project membership, replicates using the peer identity keypair, and rejects/ignores peers that are not project members via the existing role/membership checks.
Therefore:
- do not add the new REST project token to the WebSocket URL
- do not change CoMapeo project keys, membership, Noise identity, or conflict semantics
- keep the existing
ensureProjectExists()route behavior - add/retain regression evidence that an unrelated/non-member peer cannot replicate project data
The scoped REST credential must not reveal other project IDs, so it does not give a recipient values with which to probe the sync route. Project IDs are high-entropy public IDs, not sequential enumerable IDs.
7. Keep the encrypted invite format backward compatible, with optional scope metadata
Keep the v1.* encrypted invite envelope and existing URL shape.
Extend its encrypted payload with optional, non-authoritative metadata:
scope?:
| { type: 'archive' }
| { type: 'project'; projectId: string }
Project-scoped generation encrypts:
{ url, token: <server-issued scoped token>, exp, scope: { type: 'project', projectId } }
Legacy/archive-wide invites may omit scope; absence means archive-wide for persistence compatibility.
The server-issued token remains the security boundary. A client ignoring scope is still restricted by comapeo-cloud.
Update createEncryptedInvite() / redeemEncryptedInvite() and the Pages Function schemas/responses to round-trip optional scope metadata without putting credentials in the invitation URL.
8. Add project-scoped generation to the existing Settings invitation area
Preserve the current archive-wide URL + bearer-token form for backwards compatibility/advanced use.
Add a Specific project generation path in the same “Remote Archive Invites” section:
- uses the currently active remote archive credential from
useAuthStore - lists only projects belonging to the active archive (
sourceId === activeServerId) - requires a selected remote project with a real
remoteId/projectPublicId - calls
POST /projects/:projectPublicId/accessTokensusing the active archive-wide credential - passes the returned scoped token to the existing encrypted invite generator with project scope metadata
- shows the existing invite URL/code result UI and 24-hour redemption note
Error behavior:
- active connection is project-scoped / server returns 403: explain that full archive access is required to create a new project credential
- endpoint unsupported or
PROJECT_ACCESS_TOKEN_SECRETunavailable: explain that the archive must be upgraded/configured for project-scoped invitations - project disappeared: show a project-not-found error and refresh project data
- network failure: use the existing retry/error patterns
- never silently fall back to an archive-wide invitation
Do not require users to type or copy projectPublicId manually.
9. Persist scope and prevent same-archive credential downgrades/data loss
Current useAuthStore.addServer() identifies an existing archive by normalized base URL and, with allowDuplicate: true, may replace the existing token. That is unsafe once credentials can have different scopes because replacing an archive-wide token with a project token can make subsequent snapshot reconciliation omit/tombstone other cached projects.
Add optional persisted scope metadata to RemoteServer / RemoteArchiveServer:
accessScope?:
| { type: 'archive' }
| { type: 'project'; projectId: string }
Existing records without accessScope are archive-wide. This field does not need to be indexed, so no Dexie store-index migration is required.
Connection identity/redeem rules:
- Scoped invite + no matching archive record: create a project-scoped server record.
- Scoped invite + same base URL + same project scope: refresh that scoped token in place.
- Scoped invite + same base URL + another project scope: allow a second scoped server record; each sync can only discover its own project.
- Scoped invite + same base URL + existing archive-wide record: do not downgrade or replace the broader credential. Treat onboarding as already satisfied and keep the archive-wide record/data unchanged.
- Archive-wide invite/manual connection + existing scoped records on same base URL: validate/sync the archive-wide credential first; after successful full sync, consolidate by removing redundant scoped server records for that base URL while preserving the synchronized project data. Never remove working scoped credentials before the archive-wide validation/sync succeeds.
The existing remote data local IDs are based on archive base URL + project ID, so distinct project-scoped connections do not collide. During archive-wide upgrade, a successful full sync must refresh ownership/source metadata before redundant scoped server records are removed.
Rollback behavior in InviteScreen must restore the previous token and accessScope for an existing record, or delete only the newly created scoped record, matching the current rollback guarantees.
10. Existing sync/reconciliation behavior stays authoritative
For a project credential, server-side GET /projects filtering means the current sync algorithm naturally receives one project and synchronizes only that project.
Do not add a client-side projectId filter as the primary boundary.
No changes are required to CoMapeo document conflict resolution. Snapshot reconciliation remains unchanged inside each credential's visible project set; the persistence rules above prevent an accidental scope downgrade from being interpreted as authoritative deletion of projects belonging to an existing broader connection.
Delivery order
PR 1 — digidem/comapeo-cloud
Implement the server contract first:
PROJECT_ACCESS_TOKEN_SECRETconfig + README documentation- project-token encode/verify helper with versioning, random nonce, HMAC-SHA-256, constant-time verification
- authorization principal resolution for master vs project token
POST /projects/:projectPublicId/accessTokens- scoped filtering for
GET /projects - scoped authorization on every currently bearer-protected project REST route
- indistinguishable not-found behavior for out-of-scope vs nonexistent project IDs
- no changes to
/sync/:projectPublicId,PUT /projects, project keys, or CoMapeo role semantics - tests for all rules below
PR 2 — digidem/comapeo-cloud-app
After the upstream contract is merged:
- add project-access-token API client method and error handling
- extend invite payload/schema/API round-trip with optional scope metadata
- add project-scoped generation UX in Settings
- persist
accessScope - make duplicate/redeem logic scope-aware and implement upgrade/downgrade rules
- preserve rollback guarantees
- ensure scoped sync discovers only its project
- update mocks, fixtures, i18n (en/pt/es), unit/E2E tests, and
docs/remote-archive-api-spec.md
Acceptance criteria
Server security contract
-
SERVER_BEARER_TOKENretains its current archive-wide behavior. - Existing servers start and work without
PROJECT_ACCESS_TOKEN_SECRET. -
PROJECT_ACCESS_TOKEN_SECRETis validated as a 32-byte base64 secret when configured. - Archive-wide credential can mint a scoped token for an existing
projectPublicId. - Scoped credentials cannot mint new access tokens through the new endpoint.
- Tampered, malformed, or incorrectly signed scoped tokens return 401.
- Scoped
GET /projectsreturns exactly the authorized project and reveals no other project IDs/names. - Every existing bearer-protected project REST endpoint accepts the scoped token only for its authorized project.
- Out-of-scope project requests are indistinguishable from nonexistent project requests.
- Scoped token preserves existing permissions within its project, including remote-detection-alert writes.
- Rotating
PROJECT_ACCESS_TOKEN_SECRETinvalidates previously issued scoped tokens. -
/sync/:projectPublicIdand CoMapeo project membership/replication semantics are unchanged, with regression evidence that non-members cannot replicate project data.
Cloud App behavior
- Existing archive-wide invitation generation/redemption continues to work.
- A user with an active archive-wide connection can select one project and generate a project-scoped invitation without manually entering a project ID.
- Project-scoped generation never embeds the archive-wide bearer token in the generated invite.
- Server capability unavailable/unsupported produces a clear error and never falls back to archive-wide sharing.
- A project-scoped invite uses the existing
/invite?code=...flow and syncs only the authorized project. - Optional scope metadata round-trips through invite encryption/decryption while authorization remains server-enforced.
- Existing persisted server records without scope metadata are treated as archive-wide.
- Accepting a scoped invite never replaces/downgrades an existing archive-wide credential for the same base URL.
- Accepting a scoped invite for the same project refreshes that scoped credential safely.
- Distinct scoped projects on the same archive can coexist without one credential replacing the other.
- Upgrading scoped connection(s) to an archive-wide credential only removes redundant scoped records after successful archive-wide validation/sync.
- Invite rollback restores prior credential, scope, lifecycle, and sync metadata exactly when onboarding fails/cancels.
- Existing archive-wide sync/reconciliation tests remain green; scoped sync has regression coverage proving only the visible project is pulled.
- User-facing additions are translated in English, Portuguese, and Spanish.
-
docs/remote-archive-api-spec.mddocuments archive-wide vs project-scoped bearer semantics and the new token-mint endpoint.
Required tests
comapeo-cloud
Use TDD and cover at minimum:
- master token lists all projects
- scoped token lists only its project
- scoped token accesses observation/track/preset/field list/detail routes for its project
- scoped token accesses icon and attachment routes for its project
- scoped token reads/writes remote detection alerts only for its project
- each representative out-of-scope request returns the same status/body class as a nonexistent project
- project token issuance requires master credential
- unavailable signing secret returns the stable unsupported error
- token payload/signature tampering and wrong secret fail
- project token secret rotation invalidates old tokens
- existing WebSocket/project-member replication tests remain unchanged/green
comapeo-cloud-app
Follow the repository's mandatory TDD workflow and ≥80% coverage rules. Extend at minimum:
tests/unit/lib/invite-crypto.test.tstests/unit/lib/api-client.invite.test.ts- invite schema tests
InviteScreentests for project scope, rollback, no-downgrade, same-scope refresh, and multiple project scopes- auth-store/local-repository tests for persisted
accessScopeand scope-aware identity - sync/remote-archive tests proving scoped
GET /projectsoutput results in one-project sync without changing reconciliation semantics SettingsScreentests/stories for project selection, success, unsupported server, forbidden scoped issuer, and network errors- critical invite E2E flow for project-scoped onboarding
Run the normal project gates: types, ESLint, Prettier, unit coverage, E2E, screenshots/i18n where changed, and build.
Migration and compatibility
- Server DB migration: none; project tokens are stateless.
- Server deployment change: optional
PROJECT_ACCESS_TOKEN_SECRETenables minting. Existing deployments remain compatible without it. - Cloud App IndexedDB schema/index migration: none required for
accessScopebecause it is not indexed; existing rows default to archive-wide semantics. - Invite format: remains
v1.*; scope metadata is optional and non-authoritative. - Existing archive-wide codes: remain valid.
- Existing sync protocol/conflict resolution: unchanged.
- Revocation: rotate
PROJECT_ACCESS_TOKEN_SECRETto revoke all project-scoped tokens; individual token revocation is explicitly out of scope.
Out of scope
- User accounts, organizations, RBAC, read-only/read-write roles, or per-resource permissions.
- Individual project-token revocation UI/registry.
- Changing CoMapeo project identities, encryption keys, membership roles, or replication protocol.
- Adding a new
GET /projects/:idendpoint solely for this feature. - Treating encrypted invite metadata or client-side filtering as authorization.
- Hiding data that this same browser legitimately obtained earlier through a broader credential; the requirement is that the scoped credential itself cannot discover or retrieve other projects.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing the existing invitation flow through src/lib/schemas/invite.ts, src/lib/invite-crypto.ts, functions/api/invites/encrypt.ts, functions/api/invites/decrypt.ts, SettingsScreen.tsx, and InviteScreen.tsx. Then read the auth and sync entry points in src/stores/auth-store.ts, src/lib/remote-archive.ts, and src/lib/sync.ts alongside the required comapeo-cloud server contract. Done means scoped credentials are enforced server-side, scope metadata persists safely, and project-scoped onboarding and synchronization work without downgrading archive-wide access.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- authentication, authorization, backend-api-design, mobile-dev
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100