digidem / digidem/comapeo-cloud-app

feat: add project-scoped invitation codes for remote archives

Open
#237 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

agent:blocked difficulty:hard enhancement lane:spec
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:

  1. SettingsScreen accepts a remote archive URL and bearer token.
  2. createEncryptedInvite() sends { url, token, ttlHours? } to the first-party /api/invites/encrypt Pages Function.
  3. The function AES-GCM encrypts { url, token, exp } into an opaque v1.* code.
  4. /invite?code=... redeems it through /api/invites/decrypt.
  5. InviteScreen receives the archive URL and bearer token, validates /healthcheck + GET /projects, persists the credential, and runs syncRemoteArchive().
  6. syncRemoteArchive() synchronizes every project returned by GET /projects.

Relevant Cloud App surfaces:

  • src/lib/schemas/invite.ts
  • src/lib/invite-crypto.ts
  • functions/api/invites/encrypt.ts
  • functions/api/invites/decrypt.ts
  • src/lib/api-client.ts
  • src/screens/SettingsScreen.tsx
  • src/screens/InviteScreen.tsx
  • src/stores/auth-store.ts
  • src/lib/local-repositories.ts
  • src/lib/remote-archive.ts
  • src/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_TOKEN behavior 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/accessTokens using 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_SECRET unavailable: 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:

  1. Scoped invite + no matching archive record: create a project-scoped server record.
  2. Scoped invite + same base URL + same project scope: refresh that scoped token in place.
  3. Scoped invite + same base URL + another project scope: allow a second scoped server record; each sync can only discover its own project.
  4. 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.
  5. 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_SECRET config + 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_TOKEN retains its current archive-wide behavior.
  • Existing servers start and work without PROJECT_ACCESS_TOKEN_SECRET.
  • PROJECT_ACCESS_TOKEN_SECRET is 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 /projects returns 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_SECRET invalidates previously issued scoped tokens.
  • /sync/:projectPublicId and 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.md documents 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.ts
  • tests/unit/lib/api-client.invite.test.ts
  • invite schema tests
  • InviteScreen tests for project scope, rollback, no-downgrade, same-scope refresh, and multiple project scopes
  • auth-store/local-repository tests for persisted accessScope and scope-aware identity
  • sync/remote-archive tests proving scoped GET /projects output results in one-project sync without changing reconciliation semantics
  • SettingsScreen tests/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_SECRET enables minting. Existing deployments remain compatible without it.
  • Cloud App IndexedDB schema/index migration: none required for accessScope because 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_SECRET to 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/:id endpoint 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.