create-qpq-app 0.1.24 → 0.1.26

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.
Files changed (70) hide show
  1. package/package.json +2 -2
  2. package/template/README.md +4 -0
  3. package/template/apps/qpqjs/account.qpq.ts +4 -0
  4. package/template/apps/qpqjs/bootstrap.qpq.ts +13 -2
  5. package/template/apps/qpqjs/packages/service-utils/src/defineQpqjsService.ts +4 -0
  6. package/template/apps/qpqjs/services/test/models/src/SmokeProbeRecord.ts +9 -0
  7. package/template/apps/qpqjs/services/test/models/src/SmokeRunSummary.ts +8 -1
  8. package/template/apps/qpqjs/services/test/models/src/SmokeTestStatus.ts +2 -0
  9. package/template/apps/qpqjs/services/test/service/src/smoke/config/defineSmoke.ts +96 -6
  10. package/template/apps/qpqjs/services/test/service/src/smoke/constants/smokeProbe.ts +25 -0
  11. package/template/apps/qpqjs/services/test/service/src/smoke/email/index.ts +2 -0
  12. package/template/apps/qpqjs/services/test/service/src/smoke/email/onSmokeEmailReceived.ts +33 -0
  13. package/template/apps/qpqjs/services/test/service/src/smoke/email/smokeEmailMarkerId.ts +4 -0
  14. package/template/apps/qpqjs/services/test/service/src/smoke/index.ts +3 -0
  15. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/index.ts +2 -0
  16. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/onSmokeStreamRecord.ts +31 -0
  17. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/smokeStreamMarkerId.ts +8 -0
  18. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askExecuteSmokeTest.ts +3 -2
  19. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askStartSmokeRun.ts +27 -13
  20. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/summarizeSmokeRun.ts +5 -1
  21. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/index.ts +4 -0
  22. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/onSmokeFileEvent.ts +38 -0
  23. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/smokeFileEventMarkerId.ts +14 -0
  24. package/template/apps/qpqjs/services/test/service/src/smoke/tests/SmokeTestDefinition.ts +3 -0
  25. package/template/apps/qpqjs/services/test/service/src/smoke/tests/askSmokePollForMarker.ts +44 -0
  26. package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/askRunEmailReceiveTest.ts +41 -0
  27. package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/index.ts +1 -0
  28. package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/askRunEncryptedResourcesTest.ts +63 -0
  29. package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/index.ts +1 -0
  30. package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventBus/askRunEventBusTest.ts +2 -26
  31. package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventDoc/askAwaitSmokeEventDocLog.ts +2 -1
  32. package/template/apps/qpqjs/services/test/service/src/smoke/tests/index.ts +5 -0
  33. package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/askRunKeyValueStoreStreamTest.ts +60 -0
  34. package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/index.ts +1 -0
  35. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/askRunScopedKeyValueStoreTest.ts +102 -0
  36. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/index.ts +1 -0
  37. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/askRunScopedStorageDriveTest.ts +116 -0
  38. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/index.ts +1 -0
  39. package/template/apps/qpqjs/services/test/service/src/smoke/tests/smokeTestRegistry.ts +12 -0
  40. package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/askRunStorageDriveEventTest.ts +80 -0
  41. package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/index.ts +1 -0
  42. package/template/docusaurus/docs/actions/core/ai/ask-ai-prompt-stream.md +2 -2
  43. package/template/docusaurus/docs/actions/core/ai/ask-ai-prompt.md +3 -0
  44. package/template/docusaurus/docs/actions/core/file/ask-file-write-text-contents.md +1 -1
  45. package/template/docusaurus/docs/actions/core/platform/ask-platform-get-name.md +32 -0
  46. package/template/docusaurus/docs/actions/core/system/ask-get-runtime-remaining-time.md +52 -0
  47. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-append.md +3 -3
  48. package/template/docusaurus/docs/actions/features/event-doc-ai/active-chat-state.md +3 -2
  49. package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-continue.md +46 -0
  50. package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-send.md +6 -25
  51. package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-stream-turn.md +67 -0
  52. package/template/docusaurus/docs/actions/features/event-doc-ai/chat-list-state.md +2 -1
  53. package/template/docusaurus/docs/actions/features/event-doc-ai/streaming-and-status-state.md +7 -6
  54. package/template/docusaurus/docs/actions/features/web-socket-queue/ask-service-request.md +15 -0
  55. package/template/docusaurus/docs/actions/webserver/email/ask-email-parse.md +54 -0
  56. package/template/docusaurus/docs/config/config-aws/account-email-receiving.md +33 -0
  57. package/template/docusaurus/docs/config/core/crypto-key.md +3 -1
  58. package/template/docusaurus/docs/config/core/key-value-store.md +4 -3
  59. package/template/docusaurus/docs/config/core/storage-drive.md +6 -4
  60. package/template/docusaurus/docs/config/features/event-doc-ai.md +4 -2
  61. package/template/docusaurus/docs/config/features/event-doc-routes.md +1 -0
  62. package/template/docusaurus/docs/config/features/event-doc.md +2 -1
  63. package/template/docusaurus/docs/config/features/tenant-stores.md +10 -5
  64. package/template/docusaurus/docs/config/features/tenant.md +36 -12
  65. package/template/docusaurus/docs/config/features/tenanted-email-receiver.md +79 -0
  66. package/template/docusaurus/docs/config/features/tenanted-event-doc.md +22 -7
  67. package/template/docusaurus/docs/config/webserver/email-receiver.md +65 -0
  68. package/template/docusaurus/docs/config/webserver/email-receiving-domain.md +41 -0
  69. package/template/scripts/smoke/runSmokeRun.mjs +1 -1
  70. package/template/docusaurus/docs/config/config-aws/aws-kms-key.md +0 -100
@@ -63,6 +63,7 @@ The single `options` argument is an `EventDocRoutesOptions`:
63
63
  | `onPublish` | `string` | – | Name of a registered inline function. When set, every successful append of a Publish event invokes it with `{ docId, event, summary, state, previousState }` — the FOLDED document as of the publish event and as of the event before it (latest-shaped, snapshot-seeded, so hook cost tracks the burst since the last snapshot, never the log) — after the event is durably written. This is the seam for syncing a folded document into a materialized read model: use `state`, never re-fold. Errors propagate to the caller: the event has landed but the side effect did not, so the caller learns the read model may be stale. |
64
64
  | `onAppend` | `string` | – | Name of a registered inline function. When set, EVERY successful append (domain events and lifecycle events alike) invokes it with `{ docId, event, summary, state, previousState }` — the FOLDED document as of the event and as of its predecessor (latest-shaped, snapshot-seeded) — after the event is durably written. This is the seam for reacting to any mutation (e.g. broadcasting the doc's fresh fold, or diffing `previousState` vs `state` to detect a transition). Runs after `onPublish` when both fire on the same Publish event. Errors propagate to the caller: the event has landed but the side effect did not. |
65
65
  | `scopeResolver` | `string` | – | Name of a registered inline function. When set, every route invokes it with `{ event }` before running; a non-null result becomes the ambient storage scope for the whole request, transparently partitioning the collection's stores and assets (e.g. per-tenant). Null means unscoped. Omit for collections that never partition. |
66
+ | `authorise` | `string` | – | Name of a registered inline function. When set, every route invokes it with `{ event, storeName, action }` inside the resolved scope, before the handler; it throws to refuse. `action` is `read` (list, get, listEvents, render, references, listAssets, getAsset), `create`, `write` (appendEvent, createAsset) or `delete` (remove). [defineTenantedEventDoc](./tenanted-event-doc.md) sets this to the tenant authoriser, which requires the caller to hold `eventDoc:<storeName>:<action>`. |
66
67
  | `excludeRoutes` | `EventDocRouteName[]` | `[]` | Route names to leave unmounted (`'list' \| 'get' \| 'listEvents' \| 'render' \| 'references' \| 'create' \| 'appendEvent' \| 'createAsset' \| 'getAsset' \| 'listAssets' \| 'remove'`). For a collection that must own one of these itself instead of using the stock behavior — e.g. a `create` that must also perform some side effect the stock controller doesn't know about. |
67
68
 
68
69
  Append-time validation, rendering, references, and snapshotting are no longer per-route options: they come from the collection's registered `EventDocFunctions` object (`collectReferences`, `render`, `foldSnapshotViews`, and the doc type's own fold-gate `validators`), addressed by `eventDocFunctionsName(storeName, type)`. Register it with [defineDynamicFunctions](../core/dynamic-functions.md) directly (a multi-type store calling `defineEventDocRoutes` per type) or let [defineEventDoc](./event-doc.md) do it for the single-type case.
@@ -25,7 +25,7 @@ export default [
25
25
 
26
26
  ## Registering a collection's functions
27
27
 
28
- `functions` is an `EventDocFunctions` object: `{ storeName, type, foldSnapshotViews, collectReferences, render? }`. The object `createEventDocDefinition` returns — given `storeName`/`type` in its config — satisfies this shape directly, so a collection with no service-only render can register its definition verbatim. A collection that needs a render step only service code can perform (resolving linked docs, reading blob-drive assets) layers it on with `extendEventDocFunctions(definition, { render })`, which returns a new object and never mutates the definition itself.
28
+ `functions` is an `EventDocFunctions` object: `{ storeName, type, foldSnapshotViews, foldDocumentState, collectReferences, collectReferencesFromState, validateEvent, render? }`. The object `createEventDocDefinition` returns — given `storeName`/`type` in its config — satisfies this shape directly, so a collection with no service-only render can register its definition verbatim. A collection that needs a render step only service code can perform (resolving linked docs, reading blob-drive assets) layers it on with `extendEventDocFunctions(definition, { render })`, which returns a new object and never mutates the definition itself.
29
29
 
30
30
  `runtime` is a [`QpqFunctionRuntime`](../core/dynamic-functions.md#runtime--qpqfunctionruntime-required) path to that SAME export — the dynamic-functions pattern: identity is read off the object here at config time, behaviour is loaded from the path by the processors at request time. Both must point at the exact object being registered, or the registration and the runtime will disagree about what the collection can do.
31
31
 
@@ -59,6 +59,7 @@ The collection's callable surface, read for its identity (`storeName`, `type`) a
59
59
  | `foldDocumentState` | `(events, seedState?) => unknown` | yes | The document view at one point, LATEST-shaped, resumable from a stored snapshot's era-pinned document state. The read side's fold: render, references, as-of reads, and the append hooks' state derivation all go through it. |
60
60
  | `collectReferences` | `(events) => EventDocLink[]` | yes | The `EventDocLink`s this doc's whole HISTORY depends on; `[]` for a leaf doc type. Invoked by the transfer manifest walk (it exports the whole history). |
61
61
  | `collectReferencesFromState` | `(state) => EventDocLink[]` | yes | The `EventDocLink`s the CURRENT state depends on; `[]` for a leaf doc type. Invoked by the references route against a snapshot-seeded folded state. |
62
+ | `validateEvent` | `(event, state) => Nullable<string> \| AskResponse<Nullable<string>>` | yes | The append pre-write gate: checked against the state the event will land on, before the write (see [askEventDocEventAppend](../../actions/features/event-doc/ask-event-doc-event-append.md)). Return a rejection reason string to refuse the append, `null` to allow it. A collection with no domain rules can pass `() => null`. |
62
63
  | `render` | `(input: EventDocRenderInput) => EventDocRenderResult \| AskResponse<EventDocRenderResult>` | no | Render the resolved, already-folded document state (`input.state`, resolved snapshot-seeded by the route). Omit and `GET {basePath}/{id}/render` 404s as "no renderer configured". Plain function or story — the dynamic-functions processor runs either. |
63
64
 
64
65
  ### `runtime` — `QpqFunctionRuntime` (required)
@@ -47,7 +47,7 @@ The record store holds `TenantRecord` rows, derived from the tenant event doc on
47
47
 
48
48
  ```typescript
49
49
  type TenantRecord = {
50
- tenantId: string;
50
+ tenantId: TenantId;
51
51
  name: string;
52
52
  brandColors?: Record<string, string>;
53
53
  logoUrl?: string;
@@ -62,21 +62,26 @@ The membership store holds `TenantMembership` rows, one per `(user, tenant)` lin
62
62
 
63
63
  ```typescript
64
64
  type TenantMembership = {
65
- tenantId: string;
65
+ tenantId: TenantId;
66
66
  userId: string;
67
- role: TenantMembershipRole;
67
+ roles: string[];
68
+ grants: TenantPermissionGrant[];
68
69
  disabled?: boolean;
69
70
  joinedAt: QpqIsoDateTime;
70
71
  addedByUserId: string;
72
+ rolesUpdatedAt: QpqIsoDateTime;
73
+ rolesUpdatedByUserId: string;
71
74
  };
72
75
  ```
73
76
 
74
- `role` is `TenantMembershipRole.owner` or `TenantMembershipRole.member` — see [defineTenant](./tenant.md#routes-mounted) for who can change it. A `disabled` member fails the membership check everywhere (scope resolver, WebSocket scope, tenant routes) but still appears in the member list, so an owner can re-enable them.
77
+ `TenantId` is a branded string (`Brand<string, 'TenantId'>`): ids the system mints or stores are branded with `toTenantId`, and ids arriving on a request (the tenant header, a path param, a WebSocket claim) go through `askTenantIdParse`, which answers `BadRequest` for anything that is not a valid scope segment. Every tenant story takes a `TenantId`, so a user id or a scope string cannot be passed where a tenant id belongs.
78
+
79
+ `roles` are codes from the catalog passed to [defineTenant](./tenant.md) (`roles.catalog`), expanded to permissions at read time, so a catalog change takes effect on every row at once and a retired code grants nothing. `grants` are direct `{ permission, selector }` entries; a selector is `{ kind: 'all' }`, `{ kind: 'ids', ids }` or `{ kind: 'resourceKinds', kinds }`, and is the only way a permission is narrowed to specific resources. A `disabled` member fails the membership check everywhere (scope resolver, WebSocket scope, tenant routes) and holds no permissions, but still appears in the member list so an admin can re-enable them.
75
80
 
76
81
  ## Notes
77
82
 
78
83
  - The tenant event-doc collection is the source of truth; the `tenantRecords` table is a read model. The sync between them is the `askTenantOnPublish` inline function, which [defineTenant](./tenant.md) registers and wires into the collection's `onPublish` hook.
79
- - Creating a tenant writes a `TenantMembership` row with `role: owner` for the caller, so the creator becomes the tenant's first member. The same table serves both the access check (`userId`+`tenantId` keyed get) and, via its `tenantId`/`userId` GSI, a tenant's member list — there is no separate reverse index to keep in lock-step.
84
+ - Creating a tenant writes a `TenantMembership` row with `roles: ['tenantAdmin']` for the caller, so the creator becomes the tenant's first member and its first admin. The same table serves both the access check (`userId`+`tenantId` keyed get) and, via its `tenantId`/`userId` GSI, a tenant's member list — there is no separate reverse index to keep in lock-step.
80
85
  - Services that do **not** own these stores still call [defineTenant](./tenant.md) (with the same `owner`) to get the scope resolver and a cross-module reference to the membership table — they never call `defineTenantStores` themselves.
81
86
 
82
87
  ## Related
@@ -7,13 +7,13 @@ description: Wire up org/tenant support across every service in one call — the
7
7
 
8
8
  Wires up **everything for org/tenant support**, declared identically in every service that needs it. You always pass the same `owner`; what actually materializes depends on whether the current module is that owner. It returns a `QPQConfig` (an array of config settings) composed of:
9
9
 
10
- - The scope-resolver and connection-scope-resolver [inline functions](../core/inline-function.md) (`TENANT_SCOPE_RESOLVER_FN`, `TENANT_CONNECTION_SCOPE_RESOLVER_FN`) — registered in **every** service, so each can tenant-scope its own collections and WebSocket connections. The tenant collection itself is an ordinary tenanted collection too: a tenant doc lives in whatever scope the request that created it ran under (the creator's personal partition, or the active tenant when an org creates a sub-tenant), and every tenant route resolves its scope with the same resolver. The cross-scope registry surface is the membership table plus the materialized `TenantRecord` store below, both unscoped.
10
+ - The scope-resolver and connection-scope-resolver [inline functions](../core/inline-function.md) (`TENANT_SCOPE_RESOLVER_FN`, `TENANT_CONNECTION_SCOPE_RESOLVER_FN`) — registered in **every** service, so each can tenant-scope its own collections and WebSocket connections. The tenant collection itself is an ordinary tenanted collection too, with one rule of its own: a tenant doc lives under its own `TENANT#<id>` scope from creation, so it is edited from inside the tenant (header, membership, and the `eventDoc:tenants:*` permission) and nobody owns it. The creator leaving changes nothing. The cross-scope registry surface is the membership table plus the materialized `TenantRecord` store below, both unscoped.
11
11
  - The `tenantMemberships` membership table ([key-value store](../core/key-value-store.md)) — declared with `owner` everywhere, so non-owner services get a cross-module reference to the owner's table instead of their own copy.
12
12
  - Everything else, gated to the owner's deploy only via [defineServiceSettings](../core/service-settings.md):
13
13
  - The tenant stores ([defineTenantStores](./tenant-stores.md)): the tenant event-doc collection plus the materialized record table.
14
14
  - The publish-to-record-store sync: an inline function (`askTenantOnPublish`) that runs on every published tenant document, re-folds the full event log, and upserts the resulting `TenantRecord`. It is a plain upsert of the fold result, so publish retries and repair re-runs are safe.
15
15
  - The stock event-doc CRUD at `{basePath}` ([defineEventDocRoutes](./event-doc-routes.md) for the `tenants` store, `tenant` type, with the publish sync wired in as `onPublish`, and `create` excluded — see below).
16
- - The membership-gated routes at `{myTenantsBasePath}`: list my tenants, create, get record, get logo, and manage members (list, add, remove).
16
+ - The membership-gated routes at `{myTenantsBasePath}`: list my tenants, create, get record, get logo, my membership, the role catalog, and manage members (list, add, remove, set roles).
17
17
 
18
18
  - **On AWS:** on the owner's deploy, this deploys everything [defineTenantStores](./tenant-stores.md) deploys (two DynamoDB tables and an S3 bucket) plus the `tenantMemberships` table and the API Gateway routes and Lambda handlers from [defineEventDocRoutes](./event-doc-routes.md) and the tenant routes below. On every other service's deploy, only the `tenantMemberships` reference resolves (no new table); the inline functions deploy no infrastructure of their own anywhere.
19
19
 
@@ -38,16 +38,39 @@ All paths are prefixed with the version segment `/v{version}` (default `/v1`) an
38
38
 
39
39
  | Method | Path | Purpose |
40
40
  | --- | --- | --- |
41
- | `GET` | `{myTenantsBasePath}` | The authenticated user's tenants, as `EventDocSummary` rows. Runs under the request's scope: memberships homed in the caller's current partition hydrate live (drafts included); the rest hydrate from the published `TenantRecord` registry. |
42
- | `POST` | `{myTenantsBasePath}` | Create a tenant (body `{ name }`); the caller becomes its first member. Runs under the request's scope, so the new tenant doc lands in the caller's current partition. This is the only way to create a tenant — the stock `create` route is excluded at `{basePath}` so a new tenant is never made without also linking its creator. |
41
+ | `GET` | `{myTenantsBasePath}` | The authenticated user's tenants, as `EventDocSummary` rows: each enabled membership's live summary, read from that tenant's own scope (drafts included, soft-deleted excluded). |
42
+ | `POST` | `{myTenantsBasePath}` | Create a tenant (body `{ name }`); the caller becomes its first member with the built-in `tenantAdmin` role. The doc is written under the new tenant's own scope, whatever scope the request ran under. This is the only way to create a tenant — the stock `create` route is excluded at `{basePath}` so a new tenant is never made without also linking its creator. Any authenticated user may create one; nothing deletes one. |
43
43
  | `GET` | `{myTenantsBasePath}/{id}` | One tenant's materialized record (the fast path). Members only: non-members get `Forbidden`, a missing record gets `NotFound`. |
44
- | `GET` | `{myTenantsBasePath}/{id}/logo` | A presigned, short-lived URL for the tenant's logo blob. Members only: non-members get `Forbidden`; a missing record or a tenant with no logo gets `NotFound`. Presigned in the scope the tenant doc was published under (recorded on the `TenantRecord`), not the reader's own scope, since the logo asset lives in the doc's home partition. |
45
- | `GET` | `{myTenantsBasePath}/{id}/members` | The tenant's members as `TenantMember[]` (`userId`, `email`, `name`, hydrated from the user directory). Members only: non-members get `Forbidden`. |
46
- | `POST` | `{myTenantsBasePath}/{id}/members` | Add an existing user directory account to the tenant (body `{ email }`). Owner only: non-owners get `Forbidden`. No invite flow — an email with no matching account gets `NotFound`. Idempotent: re-adding a member is a no-op that still returns the `TenantMember`. |
47
- | `PATCH` | `{myTenantsBasePath}/{id}/members/{userId}` | Change a member's `role` and/or `disabled` flag (body a `TenantMemberUpdateRequest`, either field optional — omitted fields keep their current value). Owner only: non-owners get `Forbidden`; an unknown `userId` gets `NotFound`. A caller can never demote or disable themself: `BadRequest`. |
48
- | `DELETE` | `{myTenantsBasePath}/{id}/members/{userId}` | Remove a member from the tenant. Owner only: non-owners get `Forbidden`. The owner can never be removed, even by themself: `BadRequest`. |
44
+ | `GET` | `{myTenantsBasePath}/{id}/logo` | A presigned, short-lived URL for the tenant's logo blob. Members only: non-members get `Forbidden`; a missing record or a tenant with no logo gets `NotFound`. Presigned in the tenant's own scope, not the reader's, since that is where the logo asset lives. |
45
+ | `GET` | `{myTenantsBasePath}/{id}/membership` | The caller's own membership row as a `TenantCallerMembership` (the row plus the full list of permission keys it expands to, for a UI to build its nav from). Enabled members only: a missing or disabled membership gets `Forbidden`. |
46
+ | `GET` | `{myTenantsBasePath}/{id}/roles` | The tenant's role catalog as `TenantRoleOption[]` (`{ code, name }`), for a role picker. Members only: non-members get `Forbidden`. |
47
+ | `GET` | `{myTenantsBasePath}/{id}/members` | A page of the tenant's members as `TenantMember[]` (`userId`, `email`, `name`, `roles`, `disabled`, hydrated from the user directory). Members only: non-members get `Forbidden`. Query `limit` and `nextPageKey` page through. |
48
+ | `POST` | `{myTenantsBasePath}/{id}/members` | Add an existing user directory account to the tenant with no roles (body `{ email }`). Needs `tenant:members:manage`: without it, `Forbidden`. No invite flow — an email with no matching account gets `NotFound`. Idempotent: re-adding a member is a no-op that still returns the `TenantMember`. |
49
+ | `PATCH` | `{myTenantsBasePath}/{id}/members/{userId}` | Change a member's `disabled` flag (body `{ disabled? }`). Needs `tenant:members:manage`: without it, `Forbidden`; an unknown `userId` gets `NotFound`. A caller can never disable themself, and nobody may disable the last member who can assign roles: `BadRequest`. |
50
+ | `PUT` | `{myTenantsBasePath}/{id}/members/{userId}/roles` | Replace a member's `roles` (catalog codes) and `grants` (direct permission grants) wholesale (body `{ roles, grants }`). Needs `tenant:roles:assign`: without it, `Forbidden`; an unknown role code or a change that would leave nobody able to assign roles gets `BadRequest`; an unknown `userId` gets `NotFound`. |
51
+ | `DELETE` | `{myTenantsBasePath}/{id}/members/{userId}` | Remove a member from the tenant. Needs `tenant:members:manage`, except that a caller may always remove themself (leave). Either way, the last member who can assign roles is never removed. |
49
52
 
50
- On top of these, the stock event-doc route set (get, append, list events, assets, remove, and so on — everything but `create`) is mounted at `{basePath}`, named after the model type like any other collection; see [defineEventDocRoutes](./event-doc-routes.md#routes-mounted) for the list. `{basePath}` and `{myTenantsBasePath}` must be distinct and neither may be a path segment under the other: `{basePath}/{id}` matches any single segment, so a literal sibling path would be ambiguous with a tenant whose id happens to match it.
53
+ On top of these, the stock event-doc route set (get, append, list events, assets, remove, and so on — everything but `create`) is mounted at `{basePath}`, named after the model type like any other collection; see [defineEventDocRoutes](./event-doc-routes.md#routes-mounted) for the list. Inside a tenant those routes require `eventDoc:tenants:<action>`, which the built-in `tenantAdmin` role holds, so editing branding is an admin act. `{basePath}` and `{myTenantsBasePath}` must be distinct and neither may be a path segment under the other: `{basePath}/{id}` matches any single segment, so a literal sibling path would be ambiguous with a tenant whose id happens to match it.
54
+
55
+ ## Permissioned routes
56
+
57
+ `createTenantedRouteDefinition` stamps out a route family whose handlers run inside the request's typed scope after the membership gate. Its config takes a `permission` on top of the usual `knownErrors` and `schema`:
58
+
59
+ ```typescript
60
+ import { createTenantedRouteDefinition } from 'quidproquo-features';
61
+
62
+ const caseRoute = createTenantedRouteDefinition('users', {}, { [ErrorTypeEnum.Forbidden]: 403 });
63
+
64
+ export const approveCase = caseRoute(
65
+ ['POST', '/cases/{id}/approval'],
66
+ function* (event, params) { ... },
67
+ { permission: { permission: CasePermission.Approve, resourceIdParam: 'id', message: 'You cannot approve cases here.' } },
68
+ );
69
+ ```
70
+
71
+ The order is authenticate, resolve the tenant header, membership, permission, then the handler. The permission is judged against the membership row the gate already read, so it costs no extra store read. `permission` is a bare key or an object: `resourceIdParam` names the path param holding the resource id (a member with a grant scoped to specific ids is checked against that id; without it only a tenant-wide grant satisfies), `allowPersonalScope` lets the route run ungated when no tenant header is sent (default is to refuse, since a personal partition has no roles to check), and `message` replaces the default `Missing permission: <key>` text. A route with no `permission` is membership-only.
72
+
73
+ For a check that needs the loaded resource, such as its kind, call `askTenantAssertPermission(requirement, message, userDirectoryName)` from the handler's logic after reading it.
51
74
 
52
75
  ## Signature
53
76
 
@@ -67,16 +90,17 @@ The single `options` argument is a `TenantOptions` (a `TenantRoutesOptions` plus
67
90
  | `routeAuthSettings` | `RouteAuthSettings` | – (required) | Auth applied to every mounted route (see [route](../webserver/route.md)). Required here, unlike the generic event-doc routes: tenant routes are meaningless unauthenticated, since membership keys off the user. Only used on the owner's deploy. |
68
91
  | `version` | `number` | `1` | Version number for the `/v{version}` path prefix on every route. Only used on the owner's deploy. |
69
92
  | `tenantHeaderName` | `string` | `'x-qpq-tenant-id'` | The header the client sends its selected tenant id on. Exposed to the tenant routes as the `tenantHeaderName` global, which the scope resolver reads. |
93
+ | `roles` | `{ catalog?: TenantRoleCatalog; creatorRoles?: string[] }` | catalog just `tenantAdmin`; creator gets `[tenantAdmin]` | `catalog` is the app's roles, role code to `{ code, name, permissions }`, merged with the built-in `tenantAdmin` role (the reserved code may not be redefined). `creatorRoles` is what a tenant's creator is seeded with; every code must be in the catalog and at least one must hold `tenant:roles:assign`, checked at config time. Both are published as the `tenantRoles` global in every service. |
70
94
 
71
95
  ## Notes
72
96
 
73
97
  - Tenant state is event-sourced: the `tenants` event-doc collection is the audit-trailed source of truth, and the `tenantRecords` table is a read model synced on publish. Request handlers never write the record table directly.
74
98
  - The `tenants` collection is scope-resolved like any other tenanted collection (see [defineTenantedEventDoc](./tenanted-event-doc.md)): a doc is only visible/editable from the scope that owns it, including through the stock CRUD at `{basePath}`. There is no cross-scope doc read — listing memberships homed in another scope goes through the published `TenantRecord`, not the doc store.
75
- - The `TenantRecord` produced by the publish sync carries `tenantId`, `name`, `brandColors`, `logo` (an asset ref, resolved to a URL via the get-logo route above), `scope` (the storage scope the doc was published under, used to presign the logo for cross-scope readers), `createdAt`, `updatedAt`, `createdByUserId`, and a `status` derived from the summary (`deleted` when the summary has a `deletedAt`, otherwise `active`).
99
+ - The `TenantRecord` produced by the publish sync carries `tenantId`, `name`, `brandColors`, `logo` (an asset ref, resolved to a URL via the get-logo route above), `createdAt`, `updatedAt`, `createdByUserId`, and a `status` derived from the summary (`deleted` when the summary has a `deletedAt`, otherwise `active`).
76
100
  - `defineTenant` registers the scope resolver but does not apply it to anything. To tenant-scope one of your own collections, pass `TENANT_SCOPE_RESOLVER_FN` as that collection's `scopeResolver` option (or use [defineTenantedEventDoc](./tenanted-event-doc.md), which does this for you).
77
101
  - The scope resolver always resolves to a typed scope — a membership-checked `TENANT#<id>` for a request that names a tenant, or the caller's own `PERSONAL#<userId>` when it doesn't. A tenant-scoped collection or connection is never left unscoped.
78
102
  - Every service — owner and non-owner alike — calls `defineTenant` with the same `owner`. There is no separate call for non-owning services anymore: the gating happens internally via [defineServiceSettings](../core/service-settings.md).
79
- - Membership rows carry a `TenantMembershipRole` (`owner` or `member`, see [defineTenantStores](./tenant-stores.md)): the tenant's creator starts as `owner`. Owners manage membership (add/remove/update role or disabled); members merely belong. A single `tenantMemberships` table (keyed `userId`+`tenantId`, with a `tenantId`/`userId` GSI) serves both the access check and a tenant's member list, so there is no separate index to keep in lock-step.
103
+ - Membership rows carry the member's `roles` (catalog codes) and direct `grants` (see [defineTenantStores](./tenant-stores.md)): the tenant's creator starts with `roles.creatorRoles` (by default just the built-in `tenantAdmin` role), and `tenantAdmin` holds `tenant:members:manage` (add, remove, disable members), `tenant:roles:assign` (set roles) and the tenant collection's own `eventDoc:tenants:*` keys. A member with no roles can list members, read the published record, and read the role catalog, and nothing else. The last member who can assign roles can never be removed or disabled, whether by role change, disable, or removal. A single `tenantMemberships` table (keyed `userId`+`tenantId`, with a `tenantId`/`userId` GSI) serves the access check, the permission check and a tenant's member list.
80
104
 
81
105
  ## Related
82
106
 
@@ -0,0 +1,79 @@
1
+ ---
2
+ title: defineTenantedEmailReceiver
3
+ description: Let tenants register email addresses and run a story, inside the tenant's scope, for every message that arrives on one.
4
+ ---
5
+
6
+ # defineTenantedEmailReceiver
7
+
8
+ A [defineEmailReceiver](../webserver/email-receiver.md) whose messages are routed to tenants. A tenant registers addresses on the app's [receiving domain](../webserver/email-receiving-domain.md) through the routes at `basePath` (any local part, `finance.commbank`, `info.anz`, first come, global across tenants), and `onEmail` runs once per delivered registered recipient **inside that tenant's scope** with a `TenantedEmailReceivedEvent`. Mail to an address nobody registered is logged and dropped.
9
+
10
+ The recipient is what names the tenant, and the mail provider delivered the message there, so routing cannot be spoofed by a sender. Who the message is *from* is the handler's business: `message.from` plus `message.authentication` (SPF/DKIM/DMARC verdicts) is the evidence, and a per-inbox allow list is app data.
11
+
12
+ Needs [defineTenant](./tenant.md) in the same service: the routes gate on the tenant header and membership, and check `tenant:email:inboxes:manage`, which the app bundles into a role in its `roles.catalog` (`TenantEmailPermission.InboxesManage`).
13
+
14
+ ```typescript
15
+ import { defineTenantedEmailReceiver } from 'quidproquo-features';
16
+
17
+ export default [
18
+ defineTenantedEmailReceiver('inbound', {
19
+ onEmail: '/entry/email/onTenantEmail::onTenantEmail',
20
+ basePath: '/email/inboxes',
21
+ routeAuthSettings: { userDirectoryName: 'app-users' },
22
+ }),
23
+ ];
24
+ ```
25
+
26
+ The handler runs under `TENANT#<id>`, so anything it writes to a scoped drive or store is partitioned by the inbox's tenant:
27
+
28
+ ```typescript
29
+ import { AskResponse } from 'quidproquo-core';
30
+ import { TenantedEmailReceivedEvent, TenantedEmailReceivedEventResponse } from 'quidproquo-features';
31
+
32
+ export function* onTenantEmail({ message, recipient, inbox }: TenantedEmailReceivedEvent): AskResponse<TenantedEmailReceivedEventResponse> {
33
+ if (message.authentication.dkim !== 'pass') {
34
+ return;
35
+ }
36
+ // inbox.tenantId is the tenant; the ambient storage scope is already its scope
37
+ }
38
+ ```
39
+
40
+ ## Routes
41
+
42
+ All three require the tenant header (an inbox belongs to a tenant, never to a person) and `tenant:email:inboxes:manage` in it.
43
+
44
+ | Method | Path | Description |
45
+ | --- | --- | --- |
46
+ | `GET` | `{basePath}` | The tenant's inboxes as `TenantEmailInboxView[]`: the row plus `addresses`, the full address on every receiving host. |
47
+ | `POST` | `{basePath}` | Register an address (body `{ address, label? }`). The local part is lower-cased and must be a plain dot-atom (`a-z 0-9 . _ + -`, no leading/trailing/double dots, at most 64 chars); a host in the body is ignored. `BadRequest` when malformed, `Conflict` when already held by any tenant. |
48
+ | `DELETE` | `{basePath}/{address}` | Release an address the tenant holds. Another tenant's address reads as `NotFound`. |
49
+
50
+ ## Signature
51
+
52
+ ```typescript
53
+ function defineTenantedEmailReceiver(
54
+ name: string,
55
+ options: TenantedEmailReceiverOptions,
56
+ ): QPQConfig;
57
+ ```
58
+
59
+ ## Parameters
60
+
61
+ | Parameter | Type | Description |
62
+ | --- | --- | --- |
63
+ | `name` | `string` | The receiver's name, passed to the underlying `defineEmailReceiver`. |
64
+ | `options.onEmail` | `QpqFunctionRuntime` | The story run per (message, registered recipient), inside the tenant's scope. |
65
+ | `options.basePath` | `` `/${string}` `` | Root of the inbox routes. |
66
+ | `options.routeAuthSettings` | `RouteAuthSettings` | The user directory the routes authenticate against; membership is checked against it. |
67
+ | `options.version` | `number` | Route version prefix, default 1. |
68
+
69
+ ## What it declares
70
+
71
+ - The `qpq-tenant-email-inboxes` store (unscoped by necessity: a message names an inbox before any scope exists), keyed by local part with a `tenantId` index.
72
+ - The inline function registration for `onEmail`.
73
+ - The underlying `defineEmailReceiver`, whose handler does the routing.
74
+ - The three routes.
75
+
76
+ ## Related
77
+
78
+ - [defineEmailReceiver](../webserver/email-receiver.md) — the untenanted receiver this builds on, and the `EmailMessage` shape.
79
+ - [defineTenant](./tenant.md) — membership, the roles catalog, and the tenant header.
@@ -1,13 +1,26 @@
1
1
  ---
2
2
  title: defineTenantedEventDoc
3
- description: A defineEventDoc with the tenant scope resolver pre-wired, so the collection's stores and assets partition per tenant.
3
+ description: A defineEventDoc with the tenant scope resolver and authoriser pre-wired, so the collection partitions per tenant and every route requires a permission.
4
4
  ---
5
5
 
6
6
  # defineTenantedEventDoc
7
7
 
8
- A [defineEventDoc](./event-doc.md) with the tenant scope resolver pre-wired as its `scopeResolver`, so the collection's stores and assets partition per tenant: request header → membership check → tenant scope, or the caller's own personal scope when no header is sent — the collection is never unscoped. It takes the same arguments as `defineEventDoc` minus `scopeResolver`, which it fills in for you.
8
+ A [defineEventDoc](./event-doc.md) with the tenant scope resolver pre-wired as its `scopeResolver` and the tenant authoriser as its `authorise`. The collection's stores and assets partition per tenant: request header, membership check, tenant scope, or the caller's own personal scope when no header is sent. The collection is never unscoped. Inside a tenant, every route also requires the caller to hold the collection's permission for that route, `eventDoc:<storeName>:<action>` where action is `read`, `create`, `write` or `delete`. Personal-scope requests are not permission-checked, since a personal partition has no second party to protect. It takes the same arguments as `defineEventDoc` minus `scopeResolver` and `authorise`, which it fills in for you.
9
9
 
10
- The deploying service must still register the resolver implementation by calling [defineTenant](./tenant.md) (with the same `owner` used everywhere else). Use plain [defineEventDoc](./event-doc.md) for collections that never partition by tenant.
10
+ The deploying service must still register both implementations by calling [defineTenant](./tenant.md) (with the same `owner` used everywhere else). Use plain [defineEventDoc](./event-doc.md) for collections that never partition by tenant.
11
+
12
+ Bundle a collection's keys into roles with `eventDocPermissions`, which builds them from the store name so the catalog and the check cannot drift:
13
+
14
+ ```typescript
15
+ import { eventDocPermissions } from 'quidproquo-features';
16
+
17
+ const articles = eventDocPermissions('articles');
18
+
19
+ export const APP_ROLE_CATALOG = {
20
+ editor: { code: 'editor', name: 'Editor', permissions: [articles.read, articles.create, articles.write] },
21
+ reader: { code: 'reader', name: 'Reader', permissions: [articles.read] },
22
+ };
23
+ ```
11
24
 
12
25
  ```typescript
13
26
  import { defineTenantedEventDoc, defineTenant } from 'quidproquo-features';
@@ -17,7 +30,9 @@ export default [
17
30
  ...defineTenant({
18
31
  owner: { module: 'ca' },
19
32
  basePath: '/tenants',
33
+ myTenantsBasePath: '/my-tenants',
20
34
  routeAuthSettings: { userDirectoryName: 'users' },
35
+ roles: { catalog: APP_ROLE_CATALOG },
21
36
  }),
22
37
 
23
38
  ...defineTenantedEventDoc(articleDefinition, '/entry/eventDocs::articleDefinition', {
@@ -37,19 +52,19 @@ function defineTenantedEventDoc(
37
52
  ): QPQConfig;
38
53
  ```
39
54
 
40
- `TenantedEventDocCollectionOptions` is `EventDocCollectionOptions` with `scopeResolver` omitted — see [defineEventDoc](./event-doc.md#parameters) for the remaining options.
55
+ `TenantedEventDocCollectionOptions` is `EventDocCollectionOptions` with `scopeResolver` and `authorise` omitted — see [defineEventDoc](./event-doc.md#parameters) for the remaining options.
41
56
 
42
57
  ## Parameters
43
58
 
44
- Same as [defineEventDoc](./event-doc.md#parameters): `functions`, `runtime`, and `options` (`basePath`, `routeAuthSettings`, `version`, `onPublish`, `onAppend`, without `scopeResolver`, which this always sets to `TENANT_SCOPE_RESOLVER_FN`).
59
+ Same as [defineEventDoc](./event-doc.md#parameters): `functions`, `runtime`, and `options` (`basePath`, `routeAuthSettings`, `version`, `onPublish`, `onAppend`, without `scopeResolver` and `authorise`, which this always sets to `TENANT_SCOPE_RESOLVER_FN` and `TENANT_EVENT_DOC_AUTHORISER_FN`).
45
60
 
46
61
  ## Returns
47
62
 
48
- Same as [defineEventDoc](./event-doc.md) — a `QPQConfig` array with the collection's `scopeResolver` already pointed at the tenant scope resolver.
63
+ Same as [defineEventDoc](./event-doc.md) — a `QPQConfig` array with the collection's `scopeResolver` and `authorise` already pointed at the tenant implementations.
49
64
 
50
65
  ## Related
51
66
 
52
67
  - [defineEventDoc](./event-doc.md) — the underlying define this pre-configures.
53
- - [defineTenant](./tenant.md) — registers `TENANT_SCOPE_RESOLVER_FN`, the inline function this wires in by name.
68
+ - [defineTenant](./tenant.md) — registers `TENANT_SCOPE_RESOLVER_FN` and `TENANT_EVENT_DOC_AUTHORISER_FN`, the inline functions this wires in by name.
54
69
  - [defineTenantedWebSocketQueue](./tenanted-web-socket-queue.md) — the same pattern applied to a WebSocket queue's `connectionScopeResolver`.
55
70
  - [defineTenantedEventDocTransfer](./tenanted-event-doc-transfer.md) — the same pattern applied to a collection's export/import routes.
@@ -0,0 +1,65 @@
1
+ ---
2
+ title: defineEmailReceiver
3
+ description: Run a story for every email sent to the app's receiving domain.
4
+ ---
5
+
6
+ # defineEmailReceiver
7
+
8
+ Receives mail sent to **any address** at the app's [receiving domain](./email-receiving-domain.md) and runs `onEmail` once per message with an `EmailReceivedEvent` whose `message` is the parsed `EmailMessage`: who it is from, who it was delivered to, subject, bodies, attachments and the provider's SPF/DKIM verdicts. Like an `HTTPEvent` for a route, the event is all the handler sees; the transport is the receiver's own.
9
+
10
+ Dynamic inboxes need no configuration: an address is just a local part, so `<inboxId>@inbox.<domain>` works the moment the receiver exists, and mapping an inbox to a tenant is a row in a key-value store keyed on `message.recipients`.
11
+
12
+ - **On AWS:** adds one receipt rule, named from the app, environment, module and receiver, to the account's rule set ([defineAccountEmailReceiving](../config-aws/account-email-receiving.md)), matching the receiving host of every root with an S3 action into a drive the receiver owns (`email-<name>`). That drive's create handler parses each raw message with [askEmailParse](../../actions/webserver/email/ask-email-parse.md), runs `onEmail` as an inline function, and deletes the object; a message whose handler threw stays until a one-day lifecycle rule sweeps it. The drive's bucket policy lets exactly that rule write and nothing else. Synth fails if the service declares no [receiving domain](./email-receiving-domain.md).
13
+ - **On the dev server:** nothing receives mail. Deployed platforms only.
14
+
15
+ A throwing handler loses that message after the platform's retries. Catch inside the handler to do otherwise.
16
+
17
+ ```typescript
18
+ import { defineEmailReceiver } from 'quidproquo-webserver';
19
+
20
+ export default [
21
+ defineEmailReceiver('support', {
22
+ onEmail: '/entry/email/onSupportEmail::onSupportEmail',
23
+ }),
24
+ ];
25
+ ```
26
+
27
+ The handler:
28
+
29
+ ```typescript
30
+ import { AskResponse } from 'quidproquo-core';
31
+ import { EmailReceivedEvent, EmailReceivedEventResponse } from 'quidproquo-webserver';
32
+
33
+ export function* onSupportEmail({ message }: EmailReceivedEvent): AskResponse<EmailReceivedEventResponse> {
34
+ if (message.authentication.dkim !== 'pass') {
35
+ return;
36
+ }
37
+
38
+ const [recipient] = message.recipients;
39
+ // resolve the inbox from the recipient, then do the work
40
+ }
41
+ ```
42
+
43
+ ## Signature
44
+
45
+ ```typescript
46
+ function defineEmailReceiver(
47
+ name: string,
48
+ options: EmailReceiverOptions,
49
+ ): QPQConfig;
50
+ ```
51
+
52
+ Returns the receiver setting, the inline function registration for `onEmail`, and the receiver's drive, as one config fragment.
53
+
54
+ ## Parameters
55
+
56
+ | Parameter | Type | Description |
57
+ | --- | --- | --- |
58
+ | `name` | `string` | The receiver's name; part of the rule, drive and function names. |
59
+ | `options.onEmail` | `QpqFunctionRuntime` | The story run per message, with an `EmailReceivedEvent` (`{ message: EmailMessage }`) as its only argument. |
60
+
61
+ ## Related
62
+
63
+ - [askEmailParse](../../actions/webserver/email/ask-email-parse.md) — the `EmailMessage` shape, and parsing a raw message obtained some other way.
64
+ - [defineEmailReceivingDomain](./email-receiving-domain.md) — the domain mail is received on (bootstrap and service).
65
+ - [defineAccountEmailReceiving](../config-aws/account-email-receiving.md) — the account rule set (account).
@@ -0,0 +1,41 @@
1
+ ---
2
+ title: defineEmailReceivingDomain
3
+ description: The app's inbound email domain, verified with the mail provider and MX-routed to it.
4
+ ---
5
+
6
+ # defineEmailReceivingDomain
7
+
8
+ Declares the app's **receiving domain**: `<subdomain>.<env>.<root>` on every root, resolved like every other host through the app's [domain resolver](../../domains.md) (`inbox.development.example.com` with the default shape). Every [`defineEmailReceiver`](./email-receiver.md) in the app receives at this domain, at any local part.
9
+
10
+ Like [`defineDns`](./dns.md), it is declared in two places: in the bootstrap config, whose stack owns the zone and so creates the records, and in every service that declares a receiver, which resolves its rule's recipients from it. Put it next to `defineDns` in the app's shared service define.
11
+
12
+ - **On AWS:** per root, creates an SES `EmailIdentity` for the receiving host (its DKIM CNAMEs land in the host's zone) and an MX record on it pointing at `inbound-smtp.<region>.amazonaws.com`. SES email receiving is only offered in some regions; the deploy region must be one of them. The identity also lets the app send to its own inboxes while the SES account is in sandbox.
13
+
14
+ ```typescript
15
+ // bootstrap.qpq.ts, and the shared service define
16
+ import { defineDns, defineEmailReceivingDomain } from 'quidproquo-webserver';
17
+
18
+ export default [
19
+ defineDns('example.com'),
20
+ defineEmailReceivingDomain('inbox'),
21
+ ];
22
+ ```
23
+
24
+ ## Signature
25
+
26
+ ```typescript
27
+ function defineEmailReceivingDomain(
28
+ subdomain?: string,
29
+ ): EmailReceivingDomainQPQWebServerConfigSetting;
30
+ ```
31
+
32
+ ## Parameters
33
+
34
+ | Parameter | Type | Default | Description |
35
+ | --- | --- | --- | --- |
36
+ | `subdomain` | `string` | `'inbox'` | The subdomain under the site root that receives mail. |
37
+
38
+ ## Related
39
+
40
+ - [defineAccountEmailReceiving](../config-aws/account-email-receiving.md) — the account rule set the app's rules go into.
41
+ - [defineEmailReceiver](./email-receiver.md) — where received mail lands.
@@ -82,7 +82,7 @@ export const runSmokeRun = async (apiUrl, log) => {
82
82
  const run = await callApi('GET', `/smoke/run/${runId}`);
83
83
  const { summary } = run;
84
84
  log(
85
- `status=${run.status} completed ${summary.completed}/${summary.total} passed ${summary.passed} failed ${summary.failed}`
85
+ `status=${run.status} completed ${summary.completed}/${summary.total} passed ${summary.passed} failed ${summary.failed} skipped ${summary.skipped}`
86
86
  );
87
87
 
88
88
  if (run.status === 'passed' || run.status === 'failed') {
@@ -1,100 +0,0 @@
1
- ---
2
- title: defineAwsKmsKey
3
- description: Register a customer-managed KMS key to encrypt a storage drive or key-value store.
4
- ---
5
-
6
- # defineAwsKmsKey
7
-
8
- Registers an existing **customer-managed KMS key** (by ARN) and binds it to a specific [storage drive](../core/storage-drive.md) or [key-value store](../core/key-value-store.md). It is the AWS-side companion to the `encryption: true` flag on those resources: when a drive or store opts into customer-managed encryption, this setting tells the deploy which KMS key to use. Without a matching key registered here, the `encryption` flag has no key to reference.
9
-
10
- - **On AWS:** looked up by `getAwsKmsKeyForStorageDrive` / `getAwsKmsKeyForKeyValueStore` in `quidproquo-config-aws`, keyed by target type + resource name + owning module. `QpqCoreStorageDriveConstruct` and `QpqCoreKeyValueStoreConstruct` in `quidproquo-deploy-awscdk` resolve the key with `aws_kms.Key.fromKeyArn(...)` and set it as the bucket / table `encryptionKey`. The deploy also grants the service's roles the KMS actions (`kms:Decrypt`, `kms:GenerateDataKey*`, `kms:Encrypt`, `kms:ReEncrypt*`, `kms:DescribeKey`) needed to use the key. The key itself is referenced, not created — you provision and manage the key (and its key policy) separately.
11
-
12
- ```typescript
13
- import { defineAwsKmsKey, AwsKmsKeyTargetType } from 'quidproquo-config-aws';
14
-
15
- export default [
16
- defineAwsKmsKey(
17
- 'uploads-key',
18
- 'arn:aws:kms:us-east-1:123456789012:key/abcd-1234-...',
19
- AwsKmsKeyTargetType.storageDrive,
20
- { name: 'user-uploads', module: 'my-service' },
21
- ),
22
- ];
23
- ```
24
-
25
- ## Signature
26
-
27
- ```typescript
28
- function defineAwsKmsKey(
29
- keyname: string,
30
- arn: string,
31
- type: AwsKmsKeyTargetType,
32
- owner: AwsKmsKeyOwner,
33
- ): AwsKmsKeyQPQConfigSetting;
34
- ```
35
-
36
- ## Parameters
37
-
38
- ### `keyname` — `string` (required)
39
-
40
- A unique name for this key registration (its `uniqueKey` within the config). An identifier for the entry — not the AWS key alias.
41
-
42
- ### `arn` — `string` (required)
43
-
44
- The ARN of the existing customer-managed KMS key. This exact ARN is imported via `Key.fromKeyArn`.
45
-
46
- ### `type` — `AwsKmsKeyTargetType` (required)
47
-
48
- Which kind of data store this key encrypts. The lookup matches on this together with the owner, so a store only picks up a key registered for its own resource type.
49
-
50
- | Value | Encrypts |
51
- | --- | --- |
52
- | `storageDrive` | A [storage drive](../core/storage-drive.md) (S3 bucket) with `encryption: true`. |
53
- | `keyValueStore` | A [key-value store](../core/key-value-store.md) (DynamoDB table) with `encryption: true`. |
54
-
55
- ### `owner` — `AwsKmsKeyOwner` (required)
56
-
57
- Identifies the resource the key belongs to. Both fields are matched against the target store at deploy time.
58
-
59
- ```typescript
60
- type AwsKmsKeyOwner = {
61
- name: string; // the storage-drive / key-value-store name
62
- module: string; // the module that owns that resource
63
- };
64
- ```
65
-
66
- - **`name`** — the storage-drive or key-value-store name (matches the value passed to `defineStorageDrive` / `defineKeyValueStore`).
67
- - **`module`** — the module that owns that resource. For a store owned by the current service this is the service's own module name; set it to another module to key a foreign resource.
68
-
69
- ## Examples
70
-
71
- ```typescript
72
- import { defineKeyValueStore } from 'quidproquo-core';
73
- import { defineStorageDrive } from 'quidproquo-core';
74
- import { defineAwsKmsKey, AwsKmsKeyTargetType } from 'quidproquo-config-aws';
75
-
76
- export default [
77
- // A drive and a store that both request customer-managed encryption
78
- defineStorageDrive('user-uploads', { encryption: true }),
79
- defineKeyValueStore('billing', 'invoiceId', { encryption: true }),
80
-
81
- // Bind each to its KMS key
82
- defineAwsKmsKey(
83
- 'uploads-key',
84
- 'arn:aws:kms:us-east-1:123456789012:key/uploads-...',
85
- AwsKmsKeyTargetType.storageDrive,
86
- { name: 'user-uploads', module: 'my-service' },
87
- ),
88
- defineAwsKmsKey(
89
- 'billing-key',
90
- 'arn:aws:kms:us-east-1:123456789012:key/billing-...',
91
- AwsKmsKeyTargetType.keyValueStore,
92
- { name: 'billing', module: 'my-service' },
93
- ),
94
- ];
95
- ```
96
-
97
- ## Related
98
-
99
- - [defineStorageDrive](../core/storage-drive.md) — its `encryption` flag selects a key registered here.
100
- - [defineKeyValueStore](../core/key-value-store.md) — its `encryption` flag selects a key registered here.