@terreno/mcp 57.19.2 → 57.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +393 -0
- package/README.md +32 -269
- package/dist/bootstrap.d.ts +12 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +77 -122
- package/dist/bootstrap.js.map +1 -1
- package/dist/docs/guidelines/ui/guidelines/core.md +1 -1
- package/dist/docs/resources/overview.md +4 -2
- package/dist/docs/resources/patterns.md +237 -176
- package/dist/docs/resources/ui.md +48 -17
- package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
- package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
- package/dist/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
- package/dist/docs/upgrades/57.20.0.md +238 -0
- package/dist/docs/versioned/next/approval-rubric.md +30 -12
- package/dist/docs/versioned/next/explanation/README.md +5 -0
- package/dist/docs/versioned/next/explanation/account-linking.md +169 -0
- package/dist/docs/versioned/next/explanation/admin-interface.md +1 -1
- package/dist/docs/versioned/next/explanation/agent-guidelines.md +173 -0
- package/dist/docs/versioned/next/explanation/ai-development-loop.md +196 -0
- package/dist/docs/versioned/next/explanation/authentication.md +45 -1
- package/dist/docs/versioned/next/explanation/consent-forms.md +105 -0
- package/dist/docs/versioned/next/explanation/example-coverage.md +1 -0
- package/dist/docs/versioned/next/explanation/local-first-data.md +1 -1
- package/dist/docs/versioned/next/explanation/organizations.md +37 -0
- package/dist/docs/versioned/next/explanation/planning-plugins.md +4 -0
- package/dist/docs/versioned/next/explanation/responsive-design.md +143 -0
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
- package/dist/docs/versioned/next/explanation/schema-metadata.md +5 -0
- package/dist/docs/versioned/next/how-to/README.md +7 -2
- package/dist/docs/versioned/next/how-to/audit-log.md +22 -3
- package/dist/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
- package/dist/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
- package/dist/docs/versioned/next/how-to/circleci.md +15 -5
- package/dist/docs/versioned/next/how-to/configure-better-auth.md +673 -53
- package/dist/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
- package/dist/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
- package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
- package/dist/docs/versioned/next/how-to/password-reset.md +1 -1
- package/dist/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
- package/dist/docs/versioned/next/how-to/run-tests-locally.md +37 -8
- package/dist/docs/versioned/next/how-to/set-up-social-login.md +424 -0
- package/dist/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
- package/dist/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
- package/dist/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
- package/dist/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
- package/dist/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
- package/dist/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
- package/dist/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
- package/dist/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
- package/dist/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
- package/dist/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
- package/dist/docs/versioned/next/reference/README.md +1 -0
- package/dist/docs/versioned/next/reference/account-security.md +700 -0
- package/dist/docs/versioned/next/reference/admin-backend.md +3 -1
- package/dist/docs/versioned/next/reference/admin-frontend.md +225 -1
- package/dist/docs/versioned/next/reference/api.md +461 -35
- package/dist/docs/versioned/next/reference/cli.md +1 -1
- package/dist/docs/versioned/next/reference/comms.md +8 -0
- package/dist/docs/versioned/next/reference/create-terreno-app.md +3 -1
- package/dist/docs/versioned/next/reference/environment-variables.md +9 -3
- package/dist/docs/versioned/next/reference/legacy/rtk.md +19 -3
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
- package/dist/docs/versioned/next/reference/mcp-server.md +1158 -441
- package/dist/docs/versioned/next/reference/syncdb.md +8 -1
- package/dist/docs/versioned/next/reference/ui.md +1271 -27
- package/dist/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
- package/dist/docs/versioned/next/tutorials/README.md +4 -0
- package/dist/httpApp.d.ts +14 -0
- package/dist/httpApp.d.ts.map +1 -0
- package/dist/httpApp.js +99 -0
- package/dist/httpApp.js.map +1 -0
- package/dist/index.js +8 -90
- package/dist/index.js.map +1 -1
- package/dist/local/localTools.d.ts.map +1 -1
- package/dist/local/localTools.js +44 -14
- package/dist/local/localTools.js.map +1 -1
- package/dist/local/metro/metroDevSession.d.ts +18 -0
- package/dist/local/metro/metroDevSession.d.ts.map +1 -1
- package/dist/local/metro/metroDevSession.js +42 -3
- package/dist/local/metro/metroDevSession.js.map +1 -1
- package/dist/local/tools/databaseQuery.d.ts.map +1 -1
- package/dist/local/tools/databaseQuery.js +3 -13
- package/dist/local/tools/databaseQuery.js.map +1 -1
- package/dist/local/tools/databaseSchema.d.ts.map +1 -1
- package/dist/local/tools/databaseSchema.js +3 -13
- package/dist/local/tools/databaseSchema.js.map +1 -1
- package/dist/local/tools/localMongo.d.ts +10 -0
- package/dist/local/tools/localMongo.d.ts.map +1 -0
- package/dist/local/tools/localMongo.js +30 -0
- package/dist/local/tools/localMongo.js.map +1 -0
- package/dist/local/tools/runtime.d.ts +18 -2
- package/dist/local/tools/runtime.d.ts.map +1 -1
- package/dist/local/tools/runtime.js +75 -20
- package/dist/local/tools/runtime.js.map +1 -1
- package/dist/local/tools/syncDb.d.ts +11 -0
- package/dist/local/tools/syncDb.d.ts.map +1 -1
- package/dist/local/tools/syncDb.js +15 -3
- package/dist/local/tools/syncDb.js.map +1 -1
- package/dist/rateLimit.d.ts +35 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +112 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +42 -60
- package/dist/tools.js.map +1 -1
- package/package.json +4 -3
- package/src/docs/guidelines/ui/guidelines/core.md +1 -1
- package/src/docs/resources/overview.md +4 -2
- package/src/docs/resources/patterns.md +237 -176
- package/src/docs/resources/ui.md +48 -17
- package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
- package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
- package/src/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
- package/src/docs/upgrades/57.20.0.md +238 -0
- package/src/docs/versioned/next/approval-rubric.md +30 -12
- package/src/docs/versioned/next/explanation/README.md +5 -0
- package/src/docs/versioned/next/explanation/account-linking.md +169 -0
- package/src/docs/versioned/next/explanation/admin-interface.md +1 -1
- package/src/docs/versioned/next/explanation/agent-guidelines.md +173 -0
- package/src/docs/versioned/next/explanation/ai-development-loop.md +196 -0
- package/src/docs/versioned/next/explanation/authentication.md +45 -1
- package/src/docs/versioned/next/explanation/consent-forms.md +105 -0
- package/src/docs/versioned/next/explanation/example-coverage.md +1 -0
- package/src/docs/versioned/next/explanation/local-first-data.md +1 -1
- package/src/docs/versioned/next/explanation/organizations.md +37 -0
- package/src/docs/versioned/next/explanation/planning-plugins.md +4 -0
- package/src/docs/versioned/next/explanation/responsive-design.md +143 -0
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
- package/src/docs/versioned/next/explanation/schema-metadata.md +5 -0
- package/src/docs/versioned/next/how-to/README.md +7 -2
- package/src/docs/versioned/next/how-to/audit-log.md +22 -3
- package/src/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
- package/src/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
- package/src/docs/versioned/next/how-to/circleci.md +15 -5
- package/src/docs/versioned/next/how-to/configure-better-auth.md +673 -53
- package/src/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
- package/src/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
- package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
- package/src/docs/versioned/next/how-to/password-reset.md +1 -1
- package/src/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
- package/src/docs/versioned/next/how-to/run-tests-locally.md +37 -8
- package/src/docs/versioned/next/how-to/set-up-social-login.md +424 -0
- package/src/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
- package/src/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
- package/src/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
- package/src/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
- package/src/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
- package/src/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
- package/src/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
- package/src/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
- package/src/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
- package/src/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
- package/src/docs/versioned/next/reference/README.md +1 -0
- package/src/docs/versioned/next/reference/account-security.md +700 -0
- package/src/docs/versioned/next/reference/admin-backend.md +3 -1
- package/src/docs/versioned/next/reference/admin-frontend.md +225 -1
- package/src/docs/versioned/next/reference/api.md +461 -35
- package/src/docs/versioned/next/reference/cli.md +1 -1
- package/src/docs/versioned/next/reference/comms.md +8 -0
- package/src/docs/versioned/next/reference/create-terreno-app.md +3 -1
- package/src/docs/versioned/next/reference/environment-variables.md +9 -3
- package/src/docs/versioned/next/reference/legacy/rtk.md +19 -3
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
- package/src/docs/versioned/next/reference/mcp-server.md +1158 -441
- package/src/docs/versioned/next/reference/syncdb.md +8 -1
- package/src/docs/versioned/next/reference/ui.md +1271 -27
- package/src/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
- package/src/docs/versioned/next/tutorials/README.md +4 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# Provisional consent signing
|
|
2
|
+
|
|
3
|
+
Terreno's consent system today lets a user sign only for themselves: `ConsentNavigator` blocks
|
|
4
|
+
the app until the user agrees to every pending form, and admins can only read responses. Apps
|
|
5
|
+
such as Flourish need staff to record consent ahead of the user, for example after verbal
|
|
6
|
+
consent from a guardian at intake, so the form counts as signed for compliance while the user
|
|
7
|
+
still sees it and signs it themselves. This plan adds that provisional signature to the
|
|
8
|
+
consent API, ships an exported UI component apps can drop into any screen, and makes consent
|
|
9
|
+
printouts show the provisional signature.
|
|
10
|
+
|
|
11
|
+
## The idea
|
|
12
|
+
|
|
13
|
+
A `ConsentResponse` can now be **provisional**: `agreed: true`, `provisional: true`, signed by
|
|
14
|
+
another user (`provisionalSignedBy`), at a time (`agreedAt`), with a required free-text note
|
|
15
|
+
(`provisionalNote`). A new RBAC action, `consentResponse:signOnBehalf`, controls who may
|
|
16
|
+
create one. A provisional response counts as signed in `getConsentStatuses`, a new server helper
|
|
17
|
+
and endpoint. It does **not** clear the form from `/consents/pending`, so the user still
|
|
18
|
+
meets the form in the gate, now with a "Signed on your behalf by <name> on <date>" notice.
|
|
19
|
+
The user's own answer always wins: agreeing makes the status `signed`, and declining makes it
|
|
20
|
+
`declined`. Both records stay for audit. A provisional signature made in error can be voided,
|
|
21
|
+
with the void kept on record.
|
|
22
|
+
|
|
23
|
+
`@terreno/ui` exports `ProvisionalConsentSigner`: given a target `userId`, it lists that user's
|
|
24
|
+
forms with status and lets the signer read a form, fill in the note box, and sign it
|
|
25
|
+
provisionally. Consent printouts (a new print action, plus the existing PDF downloads)
|
|
26
|
+
include a "Provisional signature" block.
|
|
27
|
+
|
|
28
|
+
Rejected: a separate `ProvisionalConsent` model. A field on `ConsentResponse` keeps one audit
|
|
29
|
+
trail per user and reuses the existing admin screens, populate paths, and PDF code.
|
|
30
|
+
|
|
31
|
+
## The plan
|
|
32
|
+
|
|
33
|
+
| # | Task | Depends on | Lands in | Proves it |
|
|
34
|
+
| --- | --- | --- | --- | --- |
|
|
35
|
+
| T1 | Provisional sign endpoint + RBAC (tracer) | none | api | `bun test api/src/consentApp.test.ts api/src/rbac` |
|
|
36
|
+
| T2 | Provisional notice data on user-facing payloads | T1 | api | `bun test api/src/consentApp.test.ts` |
|
|
37
|
+
| T3 | `getConsentStatuses` helper + status endpoint + admin columns + SDK | T2 | api, example-frontend SDK | `bun test api/src/consentStatus.test.ts api/src/consentApp.test.ts` |
|
|
38
|
+
| T4 | Void a provisional signature | T3 | api | `bun test api/src/consentApp.test.ts` |
|
|
39
|
+
| T5 | Provisional notice in the consent gate | none | ui | `bun test ui/src/ConsentFormScreen.test.tsx` |
|
|
40
|
+
| T6 | `ProvisionalConsentSigner` status list + hook | none | ui | `bun test ui/src/ProvisionalConsentSigner.test.tsx` |
|
|
41
|
+
| T7 | Sign flow with note box in `ProvisionalConsentSigner` | T6 | ui | same |
|
|
42
|
+
| T8 | Print helper + provisional block in PDF template | none | ui | `bun test ui/src/pdfHtmlTemplate.test.ts` |
|
|
43
|
+
| T9 | Provisional block in admin-frontend PDF generators | T8 | admin-frontend | `bun test admin-frontend/src` (PDF + isolated) |
|
|
44
|
+
| T10 | Print buttons + provisional display in admin and user consent views | T9 | admin-frontend | `bun test admin-frontend/src/Consent*.test.tsx` |
|
|
45
|
+
| T11 | Print and void buttons in `ProvisionalConsentSigner` | T7, T8 | ui | `bun test ui/src/ProvisionalConsentSigner.test.tsx` |
|
|
46
|
+
| T12 | Example app routes + backend wiring + E2E (admin and RBAC staff) | T4, T5, T11 | example-backend, example-frontend | `bun run frontend:e2e -- provisionalConsent.spec.ts` + screenshots |
|
|
47
|
+
| T13 | UI, admin, how-to docs + changelog | T10, T12 | docs, changelog | `bun run check:changelog`, `bun run check:knip`, docs-audit skill |
|
|
48
|
+
|
|
49
|
+
Tracer: `POST /consent-responses/provisional` → `ConsentResponse{provisional: true}` → form still in `/consents/pending`.
|
|
50
|
+
Out of scope: printing blank forms, bulk provisional signing, org or relationship scoping of who can sign for whom (see Expansions).
|
|
51
|
+
Open risks: staff with `signOnBehalf` can list any user's consent statuses. You chose RBAC with no relationship check, so these reads and writes are audited (T1, T3) when `AuditApp` is registered.
|
|
52
|
+
|
|
53
|
+
## Contracts
|
|
54
|
+
|
|
55
|
+
### Model: `ConsentResponse` (additive, no migration)
|
|
56
|
+
|
|
57
|
+
| Field | Type | Notes |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `provisional` | `Boolean`, default `false` | `true` only on responses created by `/consent-responses/provisional` |
|
|
60
|
+
| `provisionalSignedBy` | `ObjectId` ref `User` | The signer. Required when `provisional` |
|
|
61
|
+
| `provisionalNote` | `String` | Required, trimmed, non-empty, max 2000 chars when `provisional` |
|
|
62
|
+
| `provisionalSignerIp` / `provisionalSignerUserAgent` | `String` | Signer's request metadata, set when `auditTrail` is on |
|
|
63
|
+
| `voidedAt` / `voidedBy` / `voidReason` | `Date` / `ObjectId` ref `User` / `String` | Set only by the void action (T4) |
|
|
64
|
+
|
|
65
|
+
For provisional responses, `agreed` is always `true` and `agreedAt` is the server time.
|
|
66
|
+
`signature`/`signedAt` stay unset. `ipAddress`/`userAgent` stay unset, because those fields mean
|
|
67
|
+
the **user's** device. `formVersionSnapshot` is always set. `contentSnapshot` is set when
|
|
68
|
+
`auditTrail` is on. Existing documents read as `provisional: false` through the schema default.
|
|
69
|
+
|
|
70
|
+
### RBAC
|
|
71
|
+
|
|
72
|
+
`terrenoStatements.consentResponse` becomes `["list", "read", "signOnBehalf"]`, with a
|
|
73
|
+
description in `terrenoStatementDescriptions`. The built-in `admin` role definition gets
|
|
74
|
+
`consentResponse: ["signOnBehalf"]`. The `admin` role upsert in `roleModel.ts` adds
|
|
75
|
+
`consentResponse` to **existing** unsealed admin roles through `mergeMissingResourcePermissions`,
|
|
76
|
+
as it already does for observability resources. Without that, existing databases such as
|
|
77
|
+
Flourish would not get the permission. `superadmin` already has `*`.
|
|
78
|
+
|
|
79
|
+
`ConsentAppOptions` gains `accessControl?: AnyTerrenoAccess`. The same rule covers sign, status,
|
|
80
|
+
and void:
|
|
81
|
+
|
|
82
|
+
| `accessControl` passed | Who may act |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| yes | Any authenticated user whose roles grant `consentResponse:signOnBehalf` |
|
|
85
|
+
| no | `user.admin` only (legacy apps without RBAC); one `logger.warn` at register |
|
|
86
|
+
|
|
87
|
+
### Status rules (`getConsentStatuses`, T3)
|
|
88
|
+
|
|
89
|
+
For each active form after `resolveConsentForms(targetUser, forms)`, look only at non-voided
|
|
90
|
+
responses for the form's **current version**:
|
|
91
|
+
|
|
92
|
+
1. If the user has their own (non-provisional) response, the latest one decides: `agreed` →
|
|
93
|
+
`signed`, declined → `declined`.
|
|
94
|
+
2. Otherwise, if a provisional response exists → `provisional`. It counts as signed.
|
|
95
|
+
3. Otherwise → `pending`.
|
|
96
|
+
|
|
97
|
+
`isConsented` is `true` for `signed` and `provisional`. Exported from `@terreno/api`:
|
|
98
|
+
`getConsentStatuses({userId, resolveConsentForms?}) → Promise<ConsentStatusRow[]>`. App code
|
|
99
|
+
uses this as the "considered signed" contract.
|
|
100
|
+
|
|
101
|
+
### API (modelRouter `collectionActions` / `instanceActions` on `/consent-responses`)
|
|
102
|
+
|
|
103
|
+
| Method + path | Body / query | Response | Errors |
|
|
104
|
+
| --- | --- | --- | --- |
|
|
105
|
+
| `POST /consent-responses/provisional` | `{userId, consentFormId, note, locale?}` | `{data: ConsentResponse}` (populated `provisionalSignedBy` name/email) | 400: missing ids, blank note, inactive form, form not in target user's resolved set, `userId` = signer. 404: unknown user or form. 403/405: no permission |
|
|
106
|
+
| `GET /consent-responses/status?userId=` | — | `{data: ConsentStatusRow[]}` | 400 missing `userId`; 404 unknown user; 403/405 |
|
|
107
|
+
| `POST /consent-responses/:id/void` | `{reason}` | `{data: ConsentResponse}` | 400: blank reason, not provisional, already voided. 403/405 |
|
|
108
|
+
|
|
109
|
+
`ConsentStatusRow = {form: ConsentFormPublic, status: "pending" | "provisional" | "signed" | "declined", isConsented, respondedAt?, provisional?: {responseId, signedAt, signedBy: {id, name, email}, note}}`.
|
|
110
|
+
|
|
111
|
+
User-facing changes (T2). The user never sees the note or the signer's request metadata:
|
|
112
|
+
|
|
113
|
+
- `GET /consents/pending`: only non-provisional responses remove a form, as today, including
|
|
114
|
+
declines. Each form carries `provisionalSignature?: {signedAt, signedByName}` from the latest
|
|
115
|
+
non-voided provisional response for its current version.
|
|
116
|
+
- `GET /consents/my`: entries add `provisional`, `provisionalSignedBy: {name} | null`, `voidedAt`.
|
|
117
|
+
No note and no signer IP/UA.
|
|
118
|
+
- `GET /consents/audit/:userId` (admin): adds every provisional and void field.
|
|
119
|
+
|
|
120
|
+
Audit: when `AuditApp` is registered, sign, void, and status reads each call `recordAuditEvent`
|
|
121
|
+
(`api/src/audit/record.ts`) with actor, target user, form id, and action. Writes are best-effort,
|
|
122
|
+
as everywhere else.
|
|
123
|
+
|
|
124
|
+
### UI exports (`@terreno/ui`)
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
<ProvisionalConsentSigner
|
|
128
|
+
api={terrenoApi} // consumer RTK api, same pattern as ConsentNavigator
|
|
129
|
+
baseUrl?: string
|
|
130
|
+
userId="..." // the user being signed for
|
|
131
|
+
onSigned?={(response) => void}
|
|
132
|
+
onError?={(error) => void}
|
|
133
|
+
variables?: Record<string, string>
|
|
134
|
+
/>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- `useProvisionalConsent(api, baseUrl)` → `{statuses, isLoading, error, refetch, sign, isSigning, voidSignature}`. It injects `getConsentStatus`, `signConsentProvisionally`, and `voidProvisionalConsent`. Mutations invalidate `ConsentStatus` and `PendingConsents`.
|
|
138
|
+
- `printConsentHtml({html})`: native → `expo-print` `printAsync`; web → hidden iframe + `contentWindow.print()`.
|
|
139
|
+
- `PdfTemplateData.provisionalSignature?: {signedAt, signedByName, note?, signerIp?, voidedAt?}` rendered by `buildConsentPdfHtml` as a "Provisional signature" section.
|
|
140
|
+
|
|
141
|
+
testIDs: `provisional-consent-signer`, `provisional-consent-row-{formId}`, `provisional-consent-status-{formId}`, `provisional-consent-open-{formId}`, `provisional-consent-note-input`, `provisional-consent-submit-button`, `provisional-consent-success`, `provisional-consent-print-{formId}`, `provisional-consent-void-{formId}`, `consent-form-provisional-notice`, `consent-history-print-{entryId}`, `consent-response-print-button`.
|
|
142
|
+
|
|
143
|
+
## Tasks
|
|
144
|
+
|
|
145
|
+
- [x] **T1** — Add provisional consent signing endpoint
|
|
146
|
+
- Depends on: none
|
|
147
|
+
- Files: api/src/types/consentResponse.ts, api/src/models/consentResponse.ts, api/src/rbac/statements.ts, api/src/rbac/roleModel.ts, api/src/rbac/roleModel.test.ts, api/src/rbac/statements.test.ts, api/src/consentApp.ts, api/src/consentApp.test.ts, docs/explanation/consent-forms.md (new), docs/reference/api.md
|
|
148
|
+
- Criteria (each a test):
|
|
149
|
+
- Admin, no `accessControl`: POST returns 200. The stored response has `provisional: true`, `agreed: true`, `provisionalSignedBy` = signer id, `provisionalNote` = trimmed note, `agreedAt` in the test window, `formVersionSnapshot` = form version, and no `ipAddress`/`userAgent`. With `auditTrail`, `provisionalSignerIp` is set.
|
|
150
|
+
- With `createAccess` and a role granting `consentResponse:signOnBehalf`, a non-admin gets 200. Without it, 403 or 405. A non-admin without `accessControl` gets 403 or 405.
|
|
151
|
+
- Blank note → 400; `userId` = signer → 400; inactive form → 400; form filtered out by `resolveConsentForms` for the target → 400; unknown user → 404.
|
|
152
|
+
- After a provisional sign, the target's `/consents/pending` still lists the form. After the target's own `/consents/respond`, it does not.
|
|
153
|
+
- Role upsert: a fresh database's Admin role includes `consentResponse:signOnBehalf`. An **existing** unsealed admin role without `consentResponse` gains it on the next upsert. A sealed role is untouched.
|
|
154
|
+
- With `AuditApp` registered, a sign writes one `AuditEvent` naming actor, target user, and form.
|
|
155
|
+
- Verification: `bun test --only-failures api/src/consentApp.test.ts api/src/rbac`
|
|
156
|
+
- Docs: create `docs/explanation/consent-forms.md` (forms, responses, versions, the gate, provisional signing, and why it doesn't clear the gate). Add a `ConsentApp` section to `docs/reference/api.md`: the options table with `accessControl`, plus the provisional endpoint.
|
|
157
|
+
- Skills: terreno:terreno-backend-api, terreno:mongoose-schema-safety, model-router-actions, terreno:backend-test-env
|
|
158
|
+
|
|
159
|
+
- [x] **T2** — Expose provisional signature notice data on user-facing consent payloads
|
|
160
|
+
- Depends on: T1
|
|
161
|
+
- Files: api/src/consentApp.ts, api/src/consentApp.test.ts, docs/reference/api.md
|
|
162
|
+
- Criteria: after a provisional sign, the `/consents/pending` form has `provisionalSignature.{signedAt, signedByName}` and no `note`. A form without one has no `provisionalSignature`. `/consents/my` entries include `provisional` and `provisionalSignedBy.name`, but no note and no signer IP/UA (asserted absent). `/consents/audit/:userId` includes all provisional fields. Existing pending/my/audit tests still pass.
|
|
163
|
+
- Verification: `bun test --only-failures api/src/consentApp.test.ts`
|
|
164
|
+
- Docs: response shapes in `docs/reference/api.md`.
|
|
165
|
+
- Skills: terreno:terreno-backend-api
|
|
166
|
+
|
|
167
|
+
- [x] **T3** — Add getConsentStatuses helper, status endpoint, and provisional admin columns
|
|
168
|
+
- Depends on: T2
|
|
169
|
+
- Files: api/src/consentStatus.ts (new), api/src/consentStatus.test.ts (new), api/src/index.ts, api/src/consentApp.ts, api/src/consentApp.test.ts, docs/reference/api.md, docs/explanation/consent-forms.md, example-frontend/store/openApiSdk.ts
|
|
170
|
+
- Criteria: `getConsentStatuses` implements the status rules, with one test each for `pending`, `provisional`, `signed`, `declined`, "user agrees after provisional → `signed`", "user declines after provisional → `declined`", "old-version responses ignored", and "resolver filters forms for the target user". It is exported from `@terreno/api`. `GET /consent-responses/status?userId=` returns its rows, with the same permission matrix as T1, and writes an `AuditEvent` per read when `AuditApp` is registered. The "Consent Responses" admin contribution adds `provisional` to `listFields` plus a boolean filter, and `provisionalSignedBy` to populate paths.
|
|
171
|
+
- Verification: `bun test --only-failures api/src/consentStatus.test.ts api/src/consentApp.test.ts`; `cd example-frontend && bun run sdk` diff contains the new operations
|
|
172
|
+
- Docs: helper and endpoint in `docs/reference/api.md`; "considered signed" rules in the explanation page.
|
|
173
|
+
- Skills: terreno:terreno-backend-api, terreno:generate-sdk
|
|
174
|
+
|
|
175
|
+
- [x] **T4** — Void a provisional consent signature
|
|
176
|
+
- Depends on: T3
|
|
177
|
+
- Files: api/src/types/consentResponse.ts, api/src/models/consentResponse.ts, api/src/consentApp.ts, api/src/consentStatus.ts, api/src/consentApp.test.ts, api/src/consentStatus.test.ts, docs/reference/api.md, example-frontend/store/openApiSdk.ts
|
|
178
|
+
- Criteria: `POST /consent-responses/:id/void` with a reason sets `voidedAt`/`voidedBy`/`voidReason` and never deletes the record. Voiding a non-provisional response, voiding twice, or a blank reason → 400. Same permission matrix as T1. After a void, `getConsentStatuses` returns `pending`, and `/consents/pending` drops `provisionalSignature`. With `AuditApp`, a void writes an `AuditEvent`.
|
|
179
|
+
- Verification: `bun test --only-failures api/src/consentApp.test.ts api/src/consentStatus.test.ts`; SDK regenerated
|
|
180
|
+
- Skills: terreno:terreno-backend-api, terreno:mongoose-schema-safety
|
|
181
|
+
|
|
182
|
+
- [x] **T5** — Show provisional notice in the consent gate
|
|
183
|
+
- Depends on: none (contract above)
|
|
184
|
+
- Files: ui/src/useConsentForms.ts, ui/src/ConsentFormScreen.tsx, ui/src/ConsentFormScreen.test.tsx
|
|
185
|
+
- Criteria: adds `ConsentFormPublic.provisionalSignature?: {signedAt, signedByName}`. When it is set, `consent-form-provisional-notice` shows "Signed on your behalf by <name> on <date>" (Luxon `DATETIME_MED`) above the content. Agree, decline, the scroll gate, checkboxes, and the signature requirement are unchanged, and existing tests pass. When it is unset, no notice shows.
|
|
186
|
+
- Verification: `bun test --only-failures ui/src/ConsentFormScreen.test.tsx`
|
|
187
|
+
- Skills: terreno:terreno-ui, accessibility-testing
|
|
188
|
+
|
|
189
|
+
- [x] **T6** — Add ProvisionalConsentSigner status list and useProvisionalConsent hook
|
|
190
|
+
- Depends on: none (contract above)
|
|
191
|
+
- Files: ui/src/useProvisionalConsent.ts, ui/src/useProvisionalConsent.test.ts, ui/src/ProvisionalConsentSigner.tsx, ui/src/ProvisionalConsentSigner.test.tsx, ui/src/lazyBoundaries/heavyOptionalExports.tsx, ui/src/index.tsx
|
|
192
|
+
- Criteria: the hook injects `getConsentStatus` (`GET {base}/consent-responses/status?userId=`) once per (api, baseUrl), following the `useConsentForms` cache pattern. The component shows a spinner while loading, an error with Retry on failure, and one row per form with title and status badge: `Pending`, `Signed provisionally by X on date`, `Signed by user on date`, or `Declined by user on date`. It is exported lazily from `@terreno/ui`, like `ConsentNavigator`. `RootImportRegression` passes.
|
|
193
|
+
- Verification: `bun test --only-failures ui/src/useProvisionalConsent.test.ts ui/src/ProvisionalConsentSigner.test.tsx ui/src/RootImportRegression.test.tsx`
|
|
194
|
+
- Skills: terreno:terreno-ui, terreno:terreno-data-fetching
|
|
195
|
+
|
|
196
|
+
- [x] **T7** — Sign on behalf with a note box in ProvisionalConsentSigner
|
|
197
|
+
- Depends on: T6
|
|
198
|
+
- Files: ui/src/useProvisionalConsent.ts, ui/src/useProvisionalConsent.test.ts, ui/src/ProvisionalConsentSigner.tsx, ui/src/ProvisionalConsentSigner.test.tsx
|
|
199
|
+
- Criteria: `pending` and `declined` rows have `provisional-consent-open-{formId}`. Opening one shows the form content read-only (`MarkdownView`, `variables` applied) and a required multiline `provisional-consent-note-input`. Submit stays disabled until the note is non-blank. Submit POSTs `{userId, consentFormId, note, locale}`, then shows `provisional-consent-success` with the signer name and time from the response, and calls `onSigned`. Server errors show inline and call `onError`.
|
|
200
|
+
- Verification: `bun test --only-failures ui/src/ProvisionalConsentSigner.test.tsx ui/src/useProvisionalConsent.test.ts`
|
|
201
|
+
- Skills: terreno:terreno-ui, terreno:terreno-data-fetching, accessibility-testing
|
|
202
|
+
|
|
203
|
+
- [x] **T8** — Add print helper and provisional block to the consent PDF template
|
|
204
|
+
- Depends on: none
|
|
205
|
+
- Files: ui/src/pdfHtmlTemplate.ts, ui/src/pdfHtmlTemplate.test.ts
|
|
206
|
+
- Criteria: `buildConsentPdfHtml` renders a "Provisional signature" section (signer, date, escaped note when given, signer IP when given, "Voided <date>" when voided) when `provisionalSignature` is set, and nothing extra otherwise. `printConsentHtml({html})` calls `expo-print` `printAsync({html})` on native. On web it appends a hidden iframe with the HTML, calls `contentWindow.print()`, and removes the iframe. Unit tests cover both paths with mocks.
|
|
207
|
+
- Verification: `bun test --only-failures ui/src/pdfHtmlTemplate.test.ts`
|
|
208
|
+
- Skills: terreno:terreno-ui
|
|
209
|
+
|
|
210
|
+
- [x] **T9** — Include provisional signatures in admin-frontend consent PDFs
|
|
211
|
+
- Depends on: T8
|
|
212
|
+
- Files: admin-frontend/src/generateConsentPdf.ts, admin-frontend/src/generateConsentHistoryPdf.ts, admin-frontend/src/useConsentHistory.ts, admin-frontend/src/isolated/generateConsentPdf.isolated.ts, admin-frontend/src/isolated/generateConsentHistoryPdf.isolated.ts, admin-frontend/src/isolated/generateConsentPdfMobile.isolated.ts, admin-frontend/src/isolated/generateConsentPdfsMobile.isolated.ts, plus their test files
|
|
213
|
+
- Criteria: response and history types carry the provisional fields. Web jsPDF output for a provisional response contains "Provisional signature", the signer name, and the date. The admin response PDF also has the note and signer IP. The user history PDF has no note. The native path passes `provisionalSignature` to `buildConsentPdfHtml`, and both mobile isolated tests assert it (extend their `@terreno/ui` mocks). Non-provisional output is unchanged. Each generator exports the template-data builder it uses, for T10.
|
|
214
|
+
- Verification: `bun test --only-failures admin-frontend/src`
|
|
215
|
+
- Skills: terreno:building-admin-interfaces
|
|
216
|
+
|
|
217
|
+
- [x] **T10** — Add print buttons and provisional display to admin and user consent views
|
|
218
|
+
- Depends on: T9
|
|
219
|
+
- Files: admin-frontend/src/ConsentResponseViewer.tsx, admin-frontend/src/ConsentResponseViewer.test.tsx, admin-frontend/src/ConsentHistory.tsx, admin-frontend/src/ConsentHistory.test.tsx
|
|
220
|
+
- Criteria: `ConsentResponseViewer` (admin) shows a "Provisional" badge, signer, date, note, and void state. `ConsentHistory` (the user's own) shows the badge, signer, and date, with no note. `consent-response-print-button` and per-entry `consent-history-print-{entryId}` call `printConsentHtml` with HTML containing the provisional block. Tests assert the HTML string. Existing download buttons still work.
|
|
221
|
+
- Verification: `bun test --only-failures admin-frontend/src/ConsentResponseViewer.test.tsx admin-frontend/src/ConsentHistory.test.tsx`
|
|
222
|
+
- Skills: terreno:building-admin-interfaces, terreno:terreno-ui
|
|
223
|
+
|
|
224
|
+
- [x] **T11** — Print and void from ProvisionalConsentSigner
|
|
225
|
+
- Depends on: T7, T8
|
|
226
|
+
- Files: ui/src/useProvisionalConsent.ts, ui/src/useProvisionalConsent.test.ts, ui/src/ProvisionalConsentSigner.tsx, ui/src/ProvisionalConsentSigner.test.tsx
|
|
227
|
+
- Criteria: `provisional`, `signed`, and `declined` rows, plus the success state, show `provisional-consent-print-{formId}`. It calls `printConsentHtml` with HTML from `buildConsentPdfHtml` containing the form content and, for provisional rows, the provisional block with the note. `provisional` rows show `provisional-consent-void-{formId}`, which asks for a reason in a `Modal`, POSTs `/void`, and refetches, so the row returns to `Pending`.
|
|
228
|
+
- Verification: `bun test --only-failures ui/src/ProvisionalConsentSigner.test.tsx ui/src/useProvisionalConsent.test.ts`
|
|
229
|
+
- Skills: terreno:terreno-ui
|
|
230
|
+
|
|
231
|
+
- [x] **T12** — Wire provisional signing into the example apps with E2E coverage
|
|
232
|
+
- Depends on: T4, T5, T11
|
|
233
|
+
- Files: example-backend/src/server.ts, example-frontend/app/consent-sign/[userId].tsx (new), example-frontend/app/admin/consent-responses/[id].tsx, example-frontend/e2e/provisionalConsent.spec.ts (new), example-frontend/e2e/helpers/consentForms.ts, example-frontend/e2e/fixtures/testUsers.ts
|
|
234
|
+
- Criteria: `ConsentApp` gets `accessControl: access`. `/consent-sign/[userId]` (outside `/admin`, so non-admin staff can reach it) renders `ProvisionalConsentSigner`. The admin response detail links to it. E2E cases:
|
|
235
|
+
1. Admin opens a pending form, fills the note, and submits. The row shows provisional.
|
|
236
|
+
2. The test user logs in and sees the gate with `consent-form-provisional-notice` and no note text. They agree and reach the app.
|
|
237
|
+
3. Admin reloads, and the row shows signed.
|
|
238
|
+
4. Setup creates a role with `consentResponse:["signOnBehalf"]` through the RBAC API and assigns it to a non-admin staff user, who signs provisionally from the same route.
|
|
239
|
+
5. A non-admin user without the role sees the error state.
|
|
240
|
+
|
|
241
|
+
Screenshots of the signer, the notice, and the print HTML go in `/opt/cursor/artifacts/`.
|
|
242
|
+
- Verification: `bun run frontend:e2e -- provisionalConsent.spec.ts`; terreno:verify-ui-changes evidence
|
|
243
|
+
- Skills: terreno:verify-ui-changes, accessibility-testing
|
|
244
|
+
|
|
245
|
+
- [x] **T13** — Document provisional consent signing and printing
|
|
246
|
+
- Depends on: T10, T12
|
|
247
|
+
- Files: docs/reference/ui.md, docs/reference/admin-frontend.md, docs/how-to/sign-consent-on-behalf.md (new), docs/how-to/README.md, docs/explanation/README.md, changelog/unreleased/provisional-consent-signing.md (new)
|
|
248
|
+
- Criteria: `ui.md` documents `ProvisionalConsentSigner`, `useProvisionalConsent`, `printConsentHtml`, and the gate notice. `admin-frontend.md` documents the print buttons and provisional display. The how-to covers granting `consentResponse:signOnBehalf` (including the existing-admin-role merge on upgrade), passing `accessControl` to `ConsentApp`, mounting the component, using `getConsentStatuses`, voiding, and printing. Both README indexes link the new pages. There is an `Added` changelog fragment.
|
|
249
|
+
- Verification: `bun run check:changelog`; `bun run check:knip`; the docs-audit skill reports no undocumented new exports
|
|
250
|
+
- Skills: terreno:update-docs, docs-audit
|
|
251
|
+
|
|
252
|
+
## Assumptions
|
|
253
|
+
|
|
254
|
+
- Endpoints are `collectionActions`/`instanceActions` on the existing `/consent-responses` modelRouter, not new hand-written `/consents` routes (repo rule: no new `router.post`).
|
|
255
|
+
- Permission lists are ANDed (`checkPermissions`, `api/src/permissions.ts:110`), so the actions build their own list from `accessControl` instead of using `action.access`.
|
|
256
|
+
- `ConsentApp` mounts routers without a path, so `TerrenoApp`'s `accessControl` does not reach them automatically. It is passed as an option.
|
|
257
|
+
- The target user is loaded with `mongoose.model("User")`. `resolveConsentForms` receives that document.
|
|
258
|
+
- The provisional signer does not tick checkboxes or draw a signature. The note is the only box (D3). Required checkboxes stay the user's to tick.
|
|
259
|
+
- `ConsentNavigator` needs no change. It renders whatever `/consents/pending` returns, and T5 adds the notice inside `ConsentFormScreen`.
|
|
260
|
+
- Build notes: `useProvisionalConsent` takes `{api, baseUrl?, userId}` (RORO), not
|
|
261
|
+
`(api, baseUrl)`. The success state's print button is `provisional-consent-success-print`,
|
|
262
|
+
so it never shares `provisional-consent-print-{formId}` with the refetched row. The admin
|
|
263
|
+
PDF template builders live in `admin-frontend/src/consentPdfTemplates.ts`.
|
|
264
|
+
- The user's own decline still clears the gate, as today. A decline after a provisional sign makes the status `declined`, not signed.
|
|
265
|
+
|
|
266
|
+
## Open questions (recommendation assumed)
|
|
267
|
+
|
|
268
|
+
| ID | Question | Recommendation | Why | If answered differently | Tasks affected |
|
|
269
|
+
| --- | --- | --- | --- | --- | --- |
|
|
270
|
+
| Q4 | What does "print consent forms" cover? | A user's form with its signatures, from the admin response view, the user's history, and the signer component | Matches "make sure the provisional is included" | Also print blank forms from the form list (X1) | T8–T11 |
|
|
271
|
+
| Q5 | When organizations are on, limit signers to users in their org? | No, RBAC only, as chosen | You picked RBAC with no relationship check; reads and writes are audited | Add an org-membership check to sign, status, and void | T1, T3, T4 |
|
|
272
|
+
|
|
273
|
+
## Expansions (follow up later)
|
|
274
|
+
|
|
275
|
+
| ID | Idea | Why it came up | Rough size | Depends on |
|
|
276
|
+
| --- | --- | --- | --- | --- |
|
|
277
|
+
| X1 | Print blank consent forms from the admin form list | "print consent forms" could mean blank forms | S | T8 |
|
|
278
|
+
| X3 | App hook `canSignOnBehalf(signer, target, form)` for relationship checks (guardian, care team) | RBAC has no target-user check | M | T1 |
|
|
279
|
+
| X4 | Bulk provisional sign of all pending forms for a user | Intake usually covers several forms | S | T7 |
|
|
280
|
+
|
|
281
|
+
## Decisions
|
|
282
|
+
|
|
283
|
+
| ID | Question asked | Answer | What it changes |
|
|
284
|
+
| --- | --- | --- | --- |
|
|
285
|
+
| D1 | Who may provisionally sign for another user? | RBAC permission | New `consentResponse:signOnBehalf` statement; no app hook or relationship check |
|
|
286
|
+
| D2 | What does the user see after a provisional signature? | Same gate + notice | Provisional responses don't clear `/consents/pending`; `ConsentFormScreen` shows a notice; the user must still agree |
|
|
287
|
+
| D3 | What is the box the provisional signer fills in? | Note only | Required `provisionalNote` text; no drawn signature from the signer |
|
|
288
|
+
| D5 | Can a provisional signature made in error be undone? | 1a: yes, void action that keeps the record | T4, void button in T11 |
|
|
289
|
+
| D6 | Should the user see the signer's note? | 2a: no, name and date only | Note only in admin, audit, printouts (T2, T5, T9, T10) |
|
|
290
|
+
| D7 | Without RBAC, fall back to `user.admin`? | 3a: yes, with a warning log | T1, T3, T4 permission matrix |
|
|
291
|
+
| D8 | Signing on declined forms: hide it, or let a newer provisional signature outrank an older decline? (asked after build: T7 offered sign on declined rows, but the status rules let the user's decline win) | ok → hide | `ProvisionalConsentSigner` offers "Sign on behalf" on pending rows only |
|
|
292
|
+
| D4 | (follow-up message) Printing | "add in the ability to print consent forms and make sure the provisional is included" | T8–T11 |
|
|
293
|
+
|
|
294
|
+
## Sign-off
|
|
295
|
+
|
|
296
|
+
Status: approved 2026-10-23
|
|
297
|
+
Cut: 1 round, 12 findings: 11 fixed (F1 status rules + `declined`, F2 existing-admin-role merge, F3 note hidden from user, F4 signer IP/UA in own fields, F5 `getConsentStatuses` export, F6 resolver check on sign, F7 audit events, F9 real verification commands, F10 mobile isolated tests, F11 per-entry testIDs + view naming, F12 RBAC staff E2E), 1 moved to questions (F8 → Q1, built on its recommendation as T4), 0 rebutted
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Fix MCP OAuth: loopback ports and machine clients
|
|
2
|
+
|
|
3
|
+
Native MCP clients such as Claude Code bind an ephemeral loopback port, and `GET /oauth/authorize` rejects that redirect unless the port matches the registered URI exactly. Headless clients cannot use OAuth at all: the token endpoint only accepts `authorization_code` and `refresh_token` for public PKCE clients.
|
|
4
|
+
|
|
5
|
+
Selected: https://github.com/TerrenoLabs/terreno/issues/1655 https://github.com/TerrenoLabs/terreno/issues/1654 https://github.com/TerrenoLabs/terreno/issues/1653, resolved 2026-10-29T21:42:00Z
|
|
6
|
+
|
|
7
|
+
## Issues
|
|
8
|
+
|
|
9
|
+
| Issue | Title | Disposition | Root cause | Task |
|
|
10
|
+
| --- | --- | --- | --- | --- |
|
|
11
|
+
| #1655 | MCP OAuth: loopback redirect URIs should match any port | fix | `api/src/mcp/oauth.ts` authorize uses exact `redirectUris.includes` | T1 |
|
|
12
|
+
| #1653 | MCP OAuth has no machine grant | fix | token route and metadata only allow public authorization-code clients | T2 |
|
|
13
|
+
| #1654 | MCP OAuth consent endpoints require JWT/Better Auth | docs | `authenticateMiddleware()` already continues when `req.user` is set | docs |
|
|
14
|
+
|
|
15
|
+
## E2E tests
|
|
16
|
+
|
|
17
|
+
None — proved by supertest HTTP tests in `api/src/mcp/oauth.test.ts`. The defects are authorization-server responses, not a browser or device flow.
|
|
18
|
+
|
|
19
|
+
## Regression cases
|
|
20
|
+
|
|
21
|
+
None — no hard-to-automate change.
|
|
22
|
+
|
|
23
|
+
## User guide
|
|
24
|
+
|
|
25
|
+
None — operator steps stay in `docs/how-to/enable-mcp-oauth.md`. No end-user guide page describes this flow.
|
|
26
|
+
|
|
27
|
+
## Tasks
|
|
28
|
+
|
|
29
|
+
- [x] **T1** — Accept any port on a registered loopback redirect URI (#1655)
|
|
30
|
+
- Depends on: none
|
|
31
|
+
- Files: `api/src/mcp/oauth.ts`, `api/src/mcp/oauth.test.ts`, `api/src/models/mcpOAuthClient.ts`, `docs/how-to/enable-mcp-oauth.md`, `docs/reference/api.md`, `changelog/unreleased/mcp-oauth-loopback-redirect.md`
|
|
32
|
+
- Root cause: `api/src/mcp/oauth.ts` authorize requires `client.redirectUris.includes(redirectUri)`, so a registered `http://127.0.0.1:33418/callback` rejects `http://127.0.0.1:<other>/callback`
|
|
33
|
+
- Proof: `api/src/mcp/oauth.test.ts` "sends the authorization code to the requested loopback port" fails on the base with `400 invalid_request` and passes on the branch with a redirect to that port
|
|
34
|
+
- Closes: #1655
|
|
35
|
+
|
|
36
|
+
- [x] **T2** — Issue client_credentials access tokens for confidential MCP clients (#1653)
|
|
37
|
+
- Depends on: T1
|
|
38
|
+
- Files: `api/src/mcp/oauth.ts`, `api/src/mcp/oauthMetadata.ts`, `api/src/mcp/oauth.test.ts`, `api/src/models/mcpOAuthClient.ts`, `api/src/models/mcpOAuthToken.ts`, `api/src/types/mcpOAuth.ts`, `api/src/index.ts`, `docs/how-to/enable-mcp-oauth.md`, `docs/reference/api.md`, `changelog/unreleased/mcp-oauth-client-credentials.md`
|
|
39
|
+
- Root cause: `SUPPORTED_GRANT_TYPES` and `/oauth/token` only accept authorization-code and refresh-token grants for public clients
|
|
40
|
+
- Proof: `api/src/mcp/oauth.test.ts` "issues an access token for client_credentials and no refresh token" fails on the base with `unsupported_grant_type` and passes on the branch; `/mcp` calls succeed as the bound user
|
|
41
|
+
- Closes: #1653
|
|
42
|
+
|
|
43
|
+
## Assumptions
|
|
44
|
+
|
|
45
|
+
- Loopback matching follows the issue: scheme, hostname, path, and query must match; the port is ignored. `localhost`, `127.0.0.1`, and `[::1]` do not match each other. No per-client opt-out.
|
|
46
|
+
- The redirect stored on the authorization request is the URI the client sent, so the code returns to the ephemeral port.
|
|
47
|
+
- `private_key_jwt` is deferred.
|
|
48
|
+
- The access token's `credential` stays `"oauth"`.
|
|
49
|
+
- Dynamic client registration stays public-only. Confidential clients are created with `McpOAuthClient.issueConfidential`, which returns the secret once.
|
|
50
|
+
- Both `client_secret_basic` and `client_secret_post` are accepted, and only the method stored on the client.
|
|
51
|
+
- Client credentials tokens have no refresh token. The document still stores a unique hash of a discarded secret so the existing unique index on `refreshTokenHash` keeps working, and `hasRefreshToken: false` makes that secret unusable for refresh.
|
|
52
|
+
- Revoking or deleting the client revokes every token family issued to it.
|
|
53
|
+
- The bound user's `disabled` flag and `tokenEpoch` apply the same way they do for authorization-code tokens.
|
|
54
|
+
|
|
55
|
+
## Open questions (recommendation assumed)
|
|
56
|
+
|
|
57
|
+
| Question | Recommendation | Why |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| Should dynamic registration mint confidential clients? | No. Only `issueConfidential`. | Registration is unauthenticated. A public registration endpoint that mints secrets widens who can create a machine identity. |
|
|
60
|
+
| `credential: "oauth"` or `"oauth-client"`? | `"oauth"` | The issue allows either. `/mcp` already treats `mcpat_` tokens as `oauth`, including scope checks and epoch revocation. |
|
|
61
|
+
|
|
62
|
+
## Consent (#1654)
|
|
63
|
+
|
|
64
|
+
Documented the existing `req.user` short-circuit in `docs/how-to/enable-mcp-oauth.md` and `docs/reference/api.md`. `resolveConsentUser` and a built-in consent page stay out. No changelog fragment: the middleware behavior was already shipped.
|
|
65
|
+
|
|
66
|
+
## Sign-off
|
|
67
|
+
|
|
68
|
+
Status: approved 2026-10-29 (tap: #1655 #1653; #1654 documented `req.user` short-circuit)
|
|
@@ -5,6 +5,7 @@ Technical reference for Terreno packages and APIs. Information-oriented, precise
|
|
|
5
5
|
## Packages
|
|
6
6
|
|
|
7
7
|
- [@terreno/api](api.md) — modelRouter, auth, permissions, setupServer
|
|
8
|
+
- [Account security](account-security.md) — `AccountSecurityApp`: per-user security summary, recovery actions (2FA reset, passkeys, providers, sessions, password reset, disable, sign-in links), security audit events
|
|
8
9
|
- [@terreno/ui](ui.md) — Components, theming, layout
|
|
9
10
|
- [@terreno/syncdb](syncdb.md) — Local-first data layer (reads, writes, offline sync)
|
|
10
11
|
- [@terreno/ai](ai.md) — AI service, GPT routes, Langfuse integration
|