# PRD: ThinkWork 0.3.0 — Admin-Configurable Runtime

## Problem Statement

Today, a new host (external provider, or any future SDK consumer) that wants to embed the ThinkWork SDK has to hard-code the ThinkWork stage's Cognito pool ID, client ID, domain, GraphQL endpoints, and its own OAuth redirect URI into build-time environment variables. Onboarding the host also requires a Terraform edit on the ThinkWork side to add the host's deep-link scheme (e.g. `myapp://oauth/callback`) to the shared Cognito mobile client's allowed callback URLs.

The result:

- Every new host is a two-repo coordination exercise with manual steps that break silently on drift.
- A single ThinkWork stage's Cognito client accumulates every consuming host's URIs, with no per-host isolation.
- A customer who self-hosts ThinkWork and a customer embedding external provider cannot meet in the middle — the customer's admin has no UX to configure ThinkWork, and the external provider binary has no way to discover which ThinkWork stage to talk to.
- Production and dev ThinkWork stages cannot be swapped without a external provider rebuild.

The customer's admin — the person installing external provider for their own workforce — has no lever to onboard their own ThinkWork deployment. They have to file a ticket and wait for two engineering teams to coordinate.

## Solution

Replace the static `EXPO_PUBLIC_THINKWORK_*` env-var config model with a **runtime-configurable** one sourced from per-company settings.

**The end-to-end admin flow:**

1. Customer deploys a ThinkWork stage. Terraform output prints a canonical stage URL plus a one-time admin API key.
2. The customer's admin opens external provider's Company Settings → ThinkWork sheet, pastes the stage URL, the admin API key, and their mobile app's deep-link scheme.
3. external provider's backend, at save time, fetches a public discovery document from the ThinkWork stage (`/.well-known/thinkwork-config.json`), registers the mobile scheme with ThinkWork's OAuth scheme registry, and writes the resolved config into `company.integration_info.thinkwork`.
4. PowerSync syncs the company row to every device in that company.
5. external provider's mobile app reads the config from the synced row at runtime. The ThinkWork SDK provider mounts against whatever config the host supplies; no env vars involved.

**The OAuth redirect problem** is solved by a new HTTPS relay:

- Cognito has exactly one fixed callback URL per ThinkWork stage: `https://<stage>/oauth/relay`. That URL goes into Cognito's allowed list at stage-deploy time and is never touched again.
- When the mobile app initiates sign-in, it first calls `/oauth/authorize?scheme=myapp` on the ThinkWork stage. The stage issues a short-lived signed JWT containing `{ scheme, tenantId, expiry, nonce }`.
- The mobile app opens Cognito's hosted UI, passing the JWT as the `state` parameter.
- Cognito redirects to the relay with `?code=...&state=<JWT>`.
- The relay verifies the JWT signature, confirms the scheme is registered for the tenant, and 302s to `<scheme>://oauth/callback?code=...`.

Onboarding a new host becomes a single form submission. Adding a second host to the same ThinkWork stage is the same form, by a different customer, against the same relay URL. No Terraform edit, no engineering coordination.

## User Stories

1. As a ThinkWork operator deploying a new stage, I want `terraform apply` to output a stage URL and a one-time admin API key, so that I can hand both to my first customer without manually creating credentials.
2. As a external provider installation admin, I want to paste a ThinkWork stage URL into my Company Settings, so that my mobile workforce gets Brain capabilities without my engineers touching code.
3. As a external provider installation admin, I want to paste my admin API key once, so that the external provider backend can register my mobile app's deep-link scheme with ThinkWork on my behalf.
4. As a external provider installation admin, I want to enter my mobile app's deep-link scheme, so that ThinkWork's OAuth relay accepts redirects back to my binary.
5. As a external provider installation admin, I want a "Test connection" button before I save, so that I don't persist broken config.
6. As a external provider installation admin, I want a human-readable label ("ThinkWork Prod") alongside the URL, so that I can confirm at a glance which stage my mobile app is talking to.
7. As a external provider installation admin, I want to optionally override Cognito pool / client / domain values, so that I can point at a stage whose discovery endpoint isn't yet exposed.
8. As a external provider mobile user, I want my Brain tab to authenticate against my company's configured ThinkWork stage, so that I see my company's agents and threads.
9. As a external provider mobile user whose company has not yet configured ThinkWork, I want the Brain tab to be absent, so that I don't see a broken feature.
10. As a external provider mobile user whose admin just changed the ThinkWork stage, I want the app to re-authenticate me against the new stage without data bleeding between configurations.
11. As a external provider mobile user on a Google-federated account, I want the OAuth redirect to land back in the external provider app correctly regardless of which ThinkWork stage my company is on.
12. As a ThinkWork operator, I want each tenant's scheme registrations to be isolated, so that one tenant on a multi-tenant stage cannot register a scheme that impacts another tenant's OAuth flow.
13. As a ThinkWork operator, I want the OAuth relay to reject `state` tokens that have been tampered with, expired, or reference unregistered schemes, so that the relay cannot be turned into an open redirect.
14. As a ThinkWork operator, I want the admin API key to be revocable without tearing down the Cognito client, so that a leaked key is recoverable.
15. As a external provider backend engineer, I want the server action that saves ThinkWork config to fail cleanly when discovery is unreachable, so that an admin sees an actionable error instead of a partial save.
16. As a external provider backend engineer, I want the admin API key stored server-side (never echoed to the browser after initial save), so that it's not leaked through client-side observability.
17. As a external provider mobile user on a fresh install, I want the app to wait until company settings have synced before evaluating whether the Brain tab should appear, so that Brain doesn't appear briefly and vanish.
18. As a external provider mobile user whose company revokes ThinkWork access, I want the Brain tab to disappear on next sync, so that I'm not left in a broken authenticated state.
19. As a ThinkWork SDK consumer, I want `ThinkworkProvider` to accept a config object whose values can change between renders, so that host apps driving config from a reactive source (PowerSync, Zustand, Redux) work naturally.
20. As a ThinkWork SDK consumer, I want the SDK's internal Cognito pool caching to respect changing pool IDs, so that swapping config mid-session actually authenticates against the new pool.
21. As a external provider installation admin on an internal network with air-gapped ThinkWork, I want the Cognito override block to let me supply values manually, so that my admin flow works without public discovery.
22. As a ThinkWork operator auditing scheme registrations, I want a record of which host label registered which scheme when, so that I can diagnose misbehaving OAuth flows.
23. As a ThinkWork operator, I want the relay to refuse redirects to schemes that aren't registered for the tenant in the state JWT, so that one tenant's registered scheme can't be hijacked by another tenant's JWT.
24. As a external provider installation admin rotating their admin API key, I want to re-enter the new key in Company Settings and have subsequent scheme registrations use it, so that rotation is a paste-and-save.
25. As a ThinkWork SDK consumer, I want a single canonical public npm package to install, so that I don't have to track a private registry or vendored tarball.
26. As a external provider mobile release manager, I want to remove all `EXPO_PUBLIC_THINKWORK_*` env vars from `eas.json`, so that production binaries aren't pinned to a single ThinkWork stage at build time.
27. As a external provider installation admin, I want Company Settings to show when config was last saved and by whom, so that changes are auditable.
28. As a ThinkWork operator, I want the discovery document to be publicly readable without auth, so that a host can validate the URL before the admin has been issued the API key.

## Implementation Decisions

### ThinkWork backend

- **New Lambda handler: `oauth-authorize`**. Handles `GET /oauth/authorize?scheme=<scheme>`. Looks up the scheme in the tenant's registry, fails if unregistered, otherwise issues a signed JWT containing `{ scheme, tenantId, nonce, expiry }` and returns both the JWT (as `state`) and the fully-formed Cognito hosted-UI authorize URL.
- **New Lambda handler: `oauth-relay`**. Handles `GET /oauth/relay?code=<code>&state=<JWT>`. Verifies the JWT signature, expiry, and that the embedded scheme is still registered for the embedded tenant. Returns a 302 to `<scheme>://oauth/callback?code=<code>` on success; 400 with a diagnostic body on any failure. Never writes to the database; pure read-and-redirect.
- **New Lambda handler: `well-known-thinkwork-config`**. Handles `GET /.well-known/thinkwork-config.json`. Returns the stage's public config (stage label, Cognito pool/client/region/domain, GraphQL URL, GraphQL WS URL, OAuth relay URL, scheme registration URL). No authentication.
- **New Lambda handler: `admin-oauth-schemes`**. Handles `POST /admin/oauth-schemes` (create) and `DELETE /admin/oauth-schemes/:id` (revoke). Auth via `Authorization: Bearer tw_admin_...`. Resolves `tenantId` from request body, trusting the admin key as stage-level superuser. Writes to `oauth_scheme_registrations`.
- **New library module: `state-jwt-signer`**. Pure HMAC-SHA256 sign and verify functions keyed off a stage secret from SSM. Deep module — simple surface (`sign(payload)`, `verify(token)`) with encapsulated cryptography, testable in isolation.
- **New library module: `admin-api-key-auth`**. Middleware that extracts the `Bearer` token from a request, hashes it, looks up the `admin_api_keys` row, returns the `tenantId` it resolves to (or 401 on miss). Deep module — all backend admin endpoints compose it uniformly.
- **New DB table: `oauth_scheme_registrations`**. Columns: `id (uuid pk)`, `tenant_id (uuid not null)`, `scheme (text not null)`, `host_label (text)`, `registered_at (timestamptz default now())`, `revoked_at (timestamptz nullable)`. Unique constraint on `(tenant_id, scheme)` where `revoked_at is null`.
- **New DB table: `admin_api_keys`**. Columns: `id`, `tenant_id`, `key_hash`, `label`, `created_at`, `revoked_at`. Created via Terraform random_password on first apply; hashed and inserted by a bootstrap Lambda.

### ThinkWork Terraform

- **Cognito mobile callback URL** simplifies to a single `https://<stage>/oauth/relay`. The per-host `myapp://`, `thinkwork://` etc. entries stay for backward compatibility during the 0.2.x → 0.3.0 migration window, with a note to remove them after the last 0.2.x consumer is off.
- **Stage admin API key**: new Terraform resource generates `tw_admin_<random>`, stores the hash in the DB and the plaintext in SSM under `/thinkwork/{stage}/admin-api-key` (surfaced as a `terraform output`, marked sensitive).
- **State JWT signing secret**: new Terraform `random_password` stored in SSM; rotation handled by re-running apply with a replace flag.
- **API Gateway routes**: four new routes wired to the four new Lambda handlers.

### ThinkWork SDK

- **Fix `cognito.ts` module singleton**: replace the `let pool: CognitoUserPool | null = null` at module scope with a Map keyed by `${userPoolId}:${userPoolClientId}`. Config swaps actually pick up the new pool; previous pool stays cached but unused, garbage-collected on unmount.
- **`signInWithGoogle` revision**: before opening Cognito's hosted UI, the SDK hits `GET {stageUrl}/oauth/authorize?scheme=<scheme>`. Uses the returned `state` JWT and `authorizeUrl` to kick off the WebBrowser flow. Post-redirect, the code lands at the host's scheme and the existing `exchangeCodeForTokens` path completes.
- **`ThinkworkConfig` type**: gains an optional `stageUrl: string` field (used only by the revised `signInWithGoogle` flow). Existing fields remain.
- **No change to the `ThinkworkProvider` prop API**. The existing `config: ThinkworkConfig` prop already re-runs downstream `useMemo` and `useCallback` hooks on identity change; host drives reactivity by passing a freshly-derived config.

### external provider web (hci + lmi)

- **New Company Settings sheet**: `thinkwork-sheet.tsx` under the existing `company-settings/` directory in both `apps/hci/` and `apps/lmi/`, following the same React Hook Form + Zod + Sheet pattern as `integration-sheet.tsx`. Fields: stage URL (required), mobile deep-link scheme (required), admin API key (required, write-only — server-side persistence), display label (optional), Cognito override block (optional, collapsed by default).
- **New server action: `updateCompanyThinkworkConfig`**. Orchestrates: fetch `/.well-known/thinkwork-config.json` from the stage URL, POST to `/admin/oauth-schemes` with the admin API key, write the resolved configuration into `company.integration_info.thinkwork`. Validates URL reachability before persisting. Returns a structured error if any step fails.
- **Test-connection button**: invokes the same resolver in dry-run mode — fetches discovery, attempts scheme registration against a test endpoint, returns the resolved config to the UI without writing. Ships with v1.
- **Admin API key storage**: server-side only. After save, the key is never returned to the client; the UI shows a masked "●●●●●" placeholder for confirmation. Rotation is a re-paste.

### external provider mobile

- **New hook: `useThinkworkConfigFromCompany`**. Reads `company.integration_info.thinkwork` via the existing PowerSync store, returns the parsed `ThinkworkConfig` shape (or `null` when absent or malformed). Suspends until the PowerSync `hasSynced` gate fires on first launch.
- **Provider mount**: `<ThinkworkProvider>` is only rendered when the hook returns a non-null config. When the hook's output changes identity (admin updated Company Settings, PowerSync re-delivered), the provider re-mounts with the new config; the fixed Cognito-singleton bug means auth restore finds no session under the new pool and flips to `signed-out` naturally.
- **Tab bar gate**: `ThinkTabBarItem` (and the tab slot in `tab-bar.tsx`) are gated on the hook's non-null return. Matches today's `isThinkworkConfigured` pattern, sourced from the hook instead of env vars.
- **Env var removal**: `eas.json` drops all `EXPO_PUBLIC_THINKWORK_*` entries across all three profiles. The external provider issue tracking the dev-in-prod eas.json workaround (`homecareintel/mobile-apps#35`) closes as a natural byproduct.

### API contracts

- `GET /.well-known/thinkwork-config.json` → `{ stageLabel, graphqlUrl, graphqlWsUrl, cognito: { region, userPoolId, userPoolClientId, hostedUiDomain }, oauthRelayUrl, schemeRegistrationUrl }`. Public. Cached aggressively at the CDN (or API Gateway) for 5 minutes.
- `GET /oauth/authorize?scheme=<scheme>` → `{ state: <JWT>, authorizeUrl: <Cognito hosted UI URL including state> }`. No auth (the scheme registry + JWT signing carries the trust). Scheme must already be registered for any tenant.
- `GET /oauth/relay?code=<code>&state=<JWT>` → `302 <scheme>://oauth/callback?code=<code>` on success, `400` on tampered/expired/unregistered.
- `POST /admin/oauth-schemes` → `{ tenantId, scheme, hostLabel }` → `201 { id, tenant_id, scheme, host_label, registered_at }`. Auth: `Authorization: Bearer tw_admin_...`.
- `DELETE /admin/oauth-schemes/:id` → `204`. Auth: same.

### Architectural decisions recorded

- Admin UI lives in the HCI and LMI back-office web apps, extending the existing `company-settings/` sheet pattern.
- Multi-tenant unit is `company` (external provider's highest-level model).
- Scheme registrations are tenant-scoped.
- Admin API keys are stage-scoped; the request body carries the `tenantId` they act on.
- The OAuth state parameter is a signed JWT issued by ThinkWork's `/oauth/authorize`, containing `{ scheme, tenantId, nonce, expiry }`. Signed with a per-stage HMAC secret from SSM.
- Failure mode when config is absent: host hides the Brain tab entirely; the provider never mounts.
- Config changes mid-session trigger natural re-authentication — no explicit force-signout logic, the provider already re-evaluates on prop identity change once the Cognito singleton is fixed.

## Testing Decisions

A good test for this feature covers **external behavior** — the contract a caller depends on — not internal structure. A test that inspects a resolver's SQL shape is brittle; a test that asserts "valid state + registered scheme → 302 to the expected URL" captures the contract.

Prior art in the codebase:

- `packages/api/src/__tests__/graphql-contract.test.ts` — structural schema-surface assertions. Apply the same pattern to assert the new public endpoints exist at the right paths.
- `packages/api/src/__tests__/orchestration-batch*.test.ts` — handler-level Vitest tests with mocked DB via drizzle. Apply the same pattern to `oauth-relay` and `admin-api-key-auth`.

### Modules with unit tests in 0.3.0

- **`state-jwt-signer`** (ThinkWork lib) — required. Test matrix: valid token round-trips, tampered token rejected, expired token rejected, token with wrong secret rejected, empty payload rejected. Pure crypto; no mocking needed.
- **`admin-api-key-auth`** (ThinkWork middleware) — added as a cheap extension. Test matrix: valid bearer resolves to `tenantId`, malformed bearer → 401, unknown bearer → 401, revoked bearer → 401, missing header → 401. Mock the `admin_api_keys` table with drizzle in-memory.

### Modules deferred (noted in Further Notes)

- **`oauth-relay` handler** (integration-level) — would want the `state-jwt-signer` and `oauth_scheme_registrations` plumbing wired end-to-end. Deferred: significant test infrastructure and the individual pieces are already covered.
- **`thinkwork-config-resolver`** (external provider server action) — exercises live HTTP calls to the stage. Deferred: mocking `fetch` cleanly for the discovery + register sequence adds setup that the current external provider web test layout doesn't support without investment.
- **`useThinkworkConfigFromCompany`** (external provider mobile hook) — integration-shaped; requires PowerSync testing infrastructure that isn't in place today.

## Implementation Sequencing

The PRD spans three repos (thinkwork, mobile-host/mobile-apps, mobile-host/web-apps). To avoid the "everyone coordinates or nothing ships" trap, the initial rollout is **bootstrap-first**:

1. **Phase 1 — ThinkWork backend + mobile, with a DB-direct bootstrap.**
   - Ship the ThinkWork backend (handlers, DB tables, Terraform, SDK `cognito.ts` singleton fix).
   - Ship the external provider mobile changes (`useThinkworkConfigFromCompany` hook, provider gate, env-var removal).
   - **Instead of the web sheet**, set the one production-customer company's `integration_info.thinkwork` via a one-off SQL script or throwaway migration. Pointed at the ThinkWork dev stage. ~30 lines.
   - End-to-end validates the runtime-configurable flow (including OAuth relay + scheme registration) without the web UX on the critical path.
2. **Phase 2 — external provider web sheet (`thinkwork-sheet.tsx` in hci + lmi).**
   - Ships afterwards. Admin-facing UX; delivers the "paste a URL and go" experience.
   - Superseded bootstrap scripts retired after phase 2 lands.

Phase 1 is the critical path; Phase 2 is customer-facing polish.

## Out of Scope

- **A ThinkWork admin web UI for managing API keys, scheme registrations, and tenant membership.** 0.3.0 ships the Terraform + API surface only. A UI is 0.4.0+ work.
- **Multi-stage switchover automation.** If an admin wants to move all their devices from ThinkWork dev to ThinkWork prod in one click, they still edit Company Settings by hand. Bulk orchestration is later.
- **Per-user ThinkWork config overrides.** 0.3.0 scopes config to the company row. A single user cannot opt into a different ThinkWork stage than their company.
- **Offline-first OAuth.** The OAuth flow still requires the device to reach the ThinkWork stage live. Not cacheable.
- **Rotating the state JWT signing secret without operator intervention.** Rotation requires a Terraform apply.
- **An in-repo external provider admin route for ThinkWork config.** The customer-facing admin path is the HCI / LMI back-office web apps only; the external provider `admin` Next.js app remains a dev / release-management surface.
- **The ThinkWork mobile app's own migration onto the new authorize-via-stage flow.** That is dogfood work tracked separately.
- **Deprecating the env-var `EXPO_PUBLIC_THINKWORK_*` fallback across all consumers.** external provider removes it; other 0.2.x consumers keep env-var config until they choose to migrate.

## Further Notes

- The Cognito mobile client's allowed callback URL list shrinks, in the long run, to one entry: `https://<stage>/oauth/relay`. The existing per-host scheme entries (`myapp://`, `thinkwork://`) remain during the migration window. A cleanup PR removes them once the last 0.2.x consumer has migrated; tracked as a follow-up.
- The `homecareintel/mobile-apps#35` tracking issue (external provider production eas.json pointing at ThinkWork dev Cognito) becomes a non-issue once this lands. Closing it as `fixed-by 0.3.0` on merge.
- The state JWT's expiry window is intentionally short (suggest 5 minutes) — the flow from `/oauth/authorize` → Cognito hosted UI → `/oauth/relay` is measured in seconds in practice, and a short window limits replay attacks.
- The `oauth-authorize` endpoint intentionally does NOT check admin-key auth; the scheme-must-be-registered check is sufficient because an unregistered scheme can't pass relay validation anyway.
- PowerSync sync-rules updates to route the `company` row to devices are infrastructure work owned by the external provider side and handled manually; not part of this PRD's implementation list.
- The integration-level tests deferred above are worth re-evaluating after the `state-jwt-signer` and `admin-api-key-auth` tests exist — those often reveal the right shape for the broader integration test to take.
- This work makes the SDK meaningfully more interesting as a public package. The docs site (tracked separately as a 0.2.0 follow-on) should add a section on multi-tenant deployment that references this PRD once merged.
- **Bootstrap script retirement**: when Phase 2 (web sheet) ships, any SQL scripts used for Phase 1 bootstrap are deleted. The web sheet's server action becomes the single path to write `integration_info.thinkwork`.
