create-qpq-app 0.1.25 → 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 (57) 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/file/ask-file-write-text-contents.md +1 -1
  43. package/template/docusaurus/docs/actions/core/platform/ask-platform-get-name.md +32 -0
  44. package/template/docusaurus/docs/actions/webserver/email/ask-email-parse.md +54 -0
  45. package/template/docusaurus/docs/config/config-aws/account-email-receiving.md +33 -0
  46. package/template/docusaurus/docs/config/core/crypto-key.md +3 -1
  47. package/template/docusaurus/docs/config/core/key-value-store.md +4 -3
  48. package/template/docusaurus/docs/config/core/storage-drive.md +5 -4
  49. package/template/docusaurus/docs/config/features/event-doc-routes.md +1 -0
  50. package/template/docusaurus/docs/config/features/tenant-stores.md +10 -5
  51. package/template/docusaurus/docs/config/features/tenant.md +36 -12
  52. package/template/docusaurus/docs/config/features/tenanted-email-receiver.md +79 -0
  53. package/template/docusaurus/docs/config/features/tenanted-event-doc.md +22 -7
  54. package/template/docusaurus/docs/config/webserver/email-receiver.md +65 -0
  55. package/template/docusaurus/docs/config/webserver/email-receiving-domain.md +41 -0
  56. package/template/scripts/smoke/runSmokeRun.mjs +1 -1
  57. package/template/docusaurus/docs/config/config-aws/aws-kms-key.md +0 -100
@@ -0,0 +1,54 @@
1
+ ---
2
+ title: askEmailParse
3
+ description: Parse a raw MIME message into addresses, subject, body, attachments and the provider's authentication verdicts.
4
+ ---
5
+
6
+ # askEmailParse
7
+
8
+ Parses a raw MIME message into an `EmailMessage`, the `message` a [defineEmailReceiver](../../../config/webserver/email-receiver.md) hands its `onEmail` inside an `EmailReceivedEvent`. A receiver parses for you; this is for a raw message an app obtained some other way.
9
+
10
+ - **Action type:** `EmailActionType.Parse`
11
+ - **At runtime:** pure on every platform (postal-mime in `quidproquo-actionprocessor-js`); throws `Invalid` when the bytes are not a MIME message.
12
+
13
+ ```typescript
14
+ import { askFileReadBinaryContents } from 'quidproquo-core';
15
+ import { askEmailParse } from 'quidproquo-webserver';
16
+
17
+ export function* askHandleMail(drive: string, filepath: string) {
18
+ const raw = yield* askFileReadBinaryContents(drive, filepath);
19
+ const message = yield* askEmailParse(raw.base64Data);
20
+
21
+ if (message.authentication.dkim !== 'pass') {
22
+ return;
23
+ }
24
+
25
+ const [recipient] = message.recipients;
26
+ // ...
27
+ }
28
+ ```
29
+
30
+ ## Signature
31
+
32
+ ```typescript
33
+ function* askEmailParse(
34
+ rawBase64: string,
35
+ ): AskResponse<EmailMessage>;
36
+ ```
37
+
38
+ ## Returns
39
+
40
+ An `EmailMessage`:
41
+
42
+ | Field | Type | Description |
43
+ | --- | --- | --- |
44
+ | `recipients` | `string[]` | The addresses the provider delivered to (`Delivered-To` / `X-Original-To`), lower-cased. Key inbox lookups on these; the `to` header can name addresses the app never saw. |
45
+ | `from`, `to`, `cc`, `replyTo` | `EmailMessageAddress[]` | Header mailboxes, `{ address, name? }`. Group addresses are flattened to their members. |
46
+ | `subject` | `string` | Empty when absent. |
47
+ | `text`, `html` | `string?` | The bodies, when present. |
48
+ | `messageId`, `date` | `string?` | As sent. |
49
+ | `attachments` | `EmailMessageAttachment[]` | `{ filename?, mimeType, base64Data }`. |
50
+ | `authentication` | `EmailMessageAuthentication` | `{ spf?, dkim?, dmarc? }`: the raw result token from the provider's `Authentication-Results` header (`pass`, `fail`, `softfail`, `none`, ...). `from` is what the sender claims; these are the only evidence for who sent it. |
51
+
52
+ ## Related
53
+
54
+ - [defineEmailReceiver](../../../config/webserver/email-receiver.md) — receives mail and calls its handler with this shape.
@@ -0,0 +1,33 @@
1
+ ---
2
+ title: defineAccountEmailReceiving
3
+ description: The account's one active SES receipt rule set, shared by every app that receives email.
4
+ ---
5
+
6
+ # defineAccountEmailReceiving
7
+
8
+ Declares the account's **SES receipt rule set** and makes it the active one. It is an account setting: declared once in `account.qpq.ts`, deployed by the `qpq-account` stack, and shared by every app's [`defineEmailReceiver`](../webserver/email-receiver.md) in that account and region.
9
+
10
+ SES allows exactly one active rule set per account per region, which is why it cannot belong to an app. Each app's inf stack adds its own rule (matched on that app's receiving domain) to this set and never activates anything itself.
11
+
12
+ - **On AWS:** creates a `ReceiptRuleSet` named `qpq-email-receiving` (fixed by the deploy package) and calls `SetActiveReceiptRuleSet` on it through a custom resource (CloudFormation can create a set but not activate one). Deleting the stack deactivates it first, since SES refuses to delete an active set. Nothing receives until this is deployed.
13
+
14
+ ```typescript
15
+ import { defineAccountEmailReceiving } from 'quidproquo-config-aws';
16
+
17
+ export default [
18
+ defineAccountEmailReceiving(),
19
+ ];
20
+ ```
21
+
22
+ ## Signature
23
+
24
+ ```typescript
25
+ function defineAccountEmailReceiving(): AccountEmailReceivingQPQConfigSetting;
26
+ ```
27
+
28
+ Takes no arguments.
29
+
30
+ ## Related
31
+
32
+ - [defineEmailReceivingDomain](../webserver/email-receiving-domain.md) — the per-app domain mail is received on (bootstrap).
33
+ - [defineEmailReceiver](../webserver/email-receiver.md) — the per-service rule that lands mail in a drive.
@@ -7,11 +7,13 @@ description: Define a crypto key, an application-level encryption key for askCry
7
7
 
8
8
  Declares a **crypto key**: a named encryption key stories use through [askCryptoEncrypt](../../actions/core/crypto/ask-crypto-encrypt.md) and [askCryptoDecrypt](../../actions/core/crypto/ask-crypto-decrypt.md). The key material never leaves the provider; your code only ever sees opaque ciphertext blobs.
9
9
 
10
- - **On AWS:** provisions a KMS customer managed key with automatic rotation enabled, addressed by a deterministic alias derived from application/module/environment, and grants the service's role use of it (`kms:GenerateDataKey*`, `kms:Decrypt`, `kms:Encrypt`, `kms:DescribeKey`). Rotation needs nothing from the app: old key material stays available for decrypt.
10
+ - **On AWS:** provisions a KMS customer managed key with automatic rotation enabled, addressed by a deterministic alias derived from application/module/environment, and grants the service's role use of it (`kms:GenerateDataKey*`, `kms:Decrypt`, `kms:Encrypt`, `kms:ReEncrypt*`, `kms:DescribeKey`). Rotation needs nothing from the app: old key material stays available for decrypt.
11
11
  - **On the dev server:** a local master key is seeded on first use at `.qpq-runtime/<app>/cryptoKeys/<service>.json`, so everything works offline with no AWS credentials.
12
12
 
13
13
  One application-level key is usually enough. Separation between callers comes from the `context` on each encrypt (see [askCryptoEncrypt](../../actions/core/crypto/ask-crypto-encrypt.md)), not from separate keys.
14
14
 
15
+ The same key also encrypts data at rest: name it as `cryptoKeyName` on a [storage drive](./storage-drive.md) or [key-value store](./key-value-store.md) and that resource is encrypted with it instead of the provider's managed keys.
16
+
15
17
  ```typescript
16
18
  import { defineCryptoKey } from 'quidproquo-core';
17
19
 
@@ -55,7 +55,8 @@ Zero or more sort keys. The list is significant:
55
55
  | `owner` | `CrossModuleOwner` | – | Declares that this store is owned by **another** module/service. Use this to read/write a store deployed elsewhere: the deploy grants this service IAM access to the foreign table instead of creating a new one. |
56
56
  | `ttlAttribute` | `string` | – | Name of a record attribute holding a Unix-epoch (seconds) timestamp. DynamoDB automatically deletes records once that time passes. |
57
57
  | `disablePointInTimeRecovery` | `boolean` | `false` | Point-in-time recovery (35-day continuous backups / restore) is on by default; set this to opt out. |
58
- | `encryption` | `boolean` | `false` | Enables customer-managed KMS encryption for the table (the KMS key comes from the service's AWS config). When a customer-managed key isn't configured, AWS-managed encryption is used instead; when `false`, DynamoDB's default provider-managed encryption still applies. |
58
+ | `cryptoKeyName` | `string` | | Encrypts the table with the named [defineCryptoKey](./crypto-key.md) instead of DynamoDB's default provider-managed encryption, which applies when omitted. The key must be declared in the same config, owned or via `owner`; a foreign key's owning service must be deployed first, since DynamoDB validates the key when the table is created. |
59
+ | `scoped` | `boolean` | `false` | Requires every action against this store to carry a scope; a call without one throws `InvalidScopeError`. [askKeyValueStoreScanAllScopes](../../actions/core/key-value-store/ask-key-value-store-scan-all-scopes.md) is the one exempt action. |
59
60
  | `onStream` | `KvsStreamSettings` | – | Turns on change data capture and runs a story for every insert/modify/remove on the store. See [Change data capture (`onStream`)](#change-data-capture-onstream). |
60
61
 
61
62
  ## Keys (`CompositeKvsKey`)
@@ -171,7 +172,7 @@ export default [
171
172
 
172
173
  // Encrypted store using a store owned by another service
173
174
  defineKeyValueStore('billing', 'accountId', [], {
174
- encryption: true,
175
+ cryptoKeyName: 'main',
175
176
  owner: { module: 'billing-service' },
176
177
  }),
177
178
  ];
@@ -182,6 +183,6 @@ export default [
182
183
  - **Read a single record:** [askKeyValueStoreGet](../../actions/core/key-value-store/ask-key-value-store-get.md) · [askKeyValueStoreGetAll](../../actions/core/key-value-store/ask-key-value-store-get-all.md)
183
184
  - **Query & scan:** [askKeyValueStoreQuery](../../actions/core/key-value-store/ask-key-value-store-query.md) · [askKeyValueStoreScan](../../actions/core/key-value-store/ask-key-value-store-scan.md)
184
185
  - **Write:** [askKeyValueStoreUpsert](../../actions/core/key-value-store/ask-key-value-store-upsert.md) · [askKeyValueStoreUpdate](../../actions/core/key-value-store/ask-key-value-store-update.md) · [askKeyValueStoreDelete](../../actions/core/key-value-store/ask-key-value-store-delete.md)
185
- - **AWS tuning:** [defineAwsKmsKey](../config-aws/aws-kms-key.md) (customer-managed encryption key for the `encryption` flag), [defineAwsDyanmoOverrideForKvs](../config-aws/aws-dyanmo-override-for-kvs.md) (back the store with a pre-existing DynamoDB table), and [defineAwsDataStoreRemovalPolicy](../config-aws/aws-data-store-removal-policy.md) (retain vs destroy the table on teardown).
186
+ - **AWS tuning:** [defineAwsDyanmoOverrideForKvs](../config-aws/aws-dyanmo-override-for-kvs.md) (back the store with a pre-existing DynamoDB table), and [defineAwsDataStoreRemovalPolicy](../config-aws/aws-data-store-removal-policy.md) (retain vs destroy the table on teardown).
186
187
  - [defineEventDocSummary](../features/event-doc-summary.md) — a real `onStream` consumer: rebuilds a document's summary record from its event log on every change.
187
188
  - **AWS implementation:** `QpqCoreKeyValueStoreConstruct` (DynamoDB table, LSIs, GSIs, TTL, PITR, KMS, IAM grants) and `QpqApiCoreKeyValueStoreStreamConstruct` (the `onStream` handler lambda + event source) in `quidproquo-deploy-awscdk`; KVS action processors in `quidproquo-actionprocessor-awslambda`.
@@ -40,7 +40,8 @@ The name of the drive. This is the name you pass as the `drive` argument to ever
40
40
  | `global` | `boolean` | `false` | Marks the drive as globally accessible across the account rather than private to the owning module. |
41
41
  | `onEvent` | `StorageDriveEvents` | – | Story functions to run when files are created or deleted in the drive. See [File events](#file-events-onevent). |
42
42
  | `lifecycleRules` | `StorageDriveLifecycleRule[]` | – | Rules that transition files to cheaper [storage tiers](#storagedrivetier) or delete them after a period. See [Lifecycle rules](#lifecycle-rules). |
43
- | `encryption` | `boolean` | `false` | Enables customer-managed KMS encryption for the drive (the KMS key comes from the service's AWS config). When `false`, provider-managed encryption still applies (SSE-S3 on AWS) — this flag only controls the KMS upgrade. |
43
+ | `cryptoKeyName` | `string` | | Encrypts the drive with the named [defineCryptoKey](./crypto-key.md) instead of the provider's managed encryption (SSE-S3 on AWS), which applies when omitted. The key must be declared in the same config, owned or via `owner`; a foreign key's owning service must be deployed first, since S3 validates the key when the bucket is created. |
44
+ | `scoped` | `boolean` | `false` | Requires every file action against this drive to carry a scope; a call without one throws `InvalidScopeError`. Incompatible with `copyPath` (`defineStorageDrive` throws at config time if both are set), since seeded content is deployed outside every scope. |
44
45
  | `lockedDown` | `boolean` | `false` | Denies object reads to every principal except the owning service's runtime role (listing and bucket management are unaffected). On AWS, the service stack must pass its `serviceRole` to `QpqCoreStorageDriveConstruct`; synth throws if a `lockedDown` drive has no `serviceRole`. |
45
46
  | `owner` | `CrossModuleOwner<'storageDriveName'>` | – | Declares that this drive is owned by **another** module/service. Use this to read/write a drive deployed elsewhere: the deploy grants this service IAM access to the foreign drive instead of creating a new bucket. `{ module, application, feature, environment, storageDriveName }` — all optional; unset parts default to the current service. |
46
47
 
@@ -64,7 +65,7 @@ defineStorageDrive('images', {
64
65
  });
65
66
  ```
66
67
 
67
- The handler story receives a `StorageDriveEvent` (exported from quidproquo-webserver) describing the drive and filepath that changed.
68
+ The handler story receives a `StorageDriveEvent` (exported from quidproquo-webserver) describing the drive and filepath that changed. On a `scoped` drive, the event also carries a `scope` field (the object's scope, with `filePaths` made scope-relative); the handler runs with it as the ambient storage scope, so file and KVS calls inside it inherit the partition.
68
69
 
69
70
  ## Lifecycle rules
70
71
 
@@ -120,7 +121,7 @@ export default [
120
121
  // Seeded, encrypted drive with a create handler
121
122
  defineStorageDrive('templates', {
122
123
  copyPath: './seed/templates',
123
- encryption: true,
124
+ cryptoKeyName: 'main',
124
125
  onEvent: {
125
126
  create: '/entry/storageDrive/onTemplateUploaded::onTemplateUploaded',
126
127
  },
@@ -139,5 +140,5 @@ export default [
139
140
  - **CORS for browser access:** [defineStorageDriveCorsSettings](../webserver/storage-drive-cors-settings.md) (quidproquo-webserver) controls the allowed browser origins for direct-to-drive uploads/downloads.
140
141
  - [defineFederatedModuleStore](./federated-module-store.md) — loads a service's story code from a drive declared here.
141
142
  - [defineEventDocSummary](../features/event-doc-summary.md) — declares an event-document collection's asset bucket as a drive of this kind.
142
- - **AWS tuning:** [defineAwsKmsKey](../config-aws/aws-kms-key.md) (customer-managed encryption key for the `encryption` flag) and [defineAwsDataStoreRemovalPolicy](../config-aws/aws-data-store-removal-policy.md) (retain vs destroy the bucket on teardown).
143
+ - **AWS tuning:** [defineAwsDataStoreRemovalPolicy](../config-aws/aws-data-store-removal-policy.md) (retain vs destroy the bucket on teardown).
143
144
  - **AWS implementation:** `QpqCoreStorageDriveConstruct` (bucket, lifecycle, KMS, IAM grants) in `quidproquo-deploy-awscdk`; file action processors in `quidproquo-actionprocessor-awslambda`.
@@ -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.
@@ -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') {