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.
- package/package.json +2 -2
- package/template/README.md +4 -0
- package/template/apps/qpqjs/account.qpq.ts +4 -0
- package/template/apps/qpqjs/bootstrap.qpq.ts +13 -2
- package/template/apps/qpqjs/packages/service-utils/src/defineQpqjsService.ts +4 -0
- package/template/apps/qpqjs/services/test/models/src/SmokeProbeRecord.ts +9 -0
- package/template/apps/qpqjs/services/test/models/src/SmokeRunSummary.ts +8 -1
- package/template/apps/qpqjs/services/test/models/src/SmokeTestStatus.ts +2 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/config/defineSmoke.ts +96 -6
- package/template/apps/qpqjs/services/test/service/src/smoke/constants/smokeProbe.ts +25 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/email/index.ts +2 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/email/onSmokeEmailReceived.ts +33 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/email/smokeEmailMarkerId.ts +4 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/index.ts +3 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/index.ts +2 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/onSmokeStreamRecord.ts +31 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/smokeStreamMarkerId.ts +8 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askExecuteSmokeTest.ts +3 -2
- package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askStartSmokeRun.ts +27 -13
- package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/summarizeSmokeRun.ts +5 -1
- package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/index.ts +4 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/onSmokeFileEvent.ts +38 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/smokeFileEventMarkerId.ts +14 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/SmokeTestDefinition.ts +3 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/askSmokePollForMarker.ts +44 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/askRunEmailReceiveTest.ts +41 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/askRunEncryptedResourcesTest.ts +63 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventBus/askRunEventBusTest.ts +2 -26
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventDoc/askAwaitSmokeEventDocLog.ts +2 -1
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/index.ts +5 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/askRunKeyValueStoreStreamTest.ts +60 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/askRunScopedKeyValueStoreTest.ts +102 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/askRunScopedStorageDriveTest.ts +116 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/smokeTestRegistry.ts +12 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/askRunStorageDriveEventTest.ts +80 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/index.ts +1 -0
- package/template/docusaurus/docs/actions/core/file/ask-file-write-text-contents.md +1 -1
- package/template/docusaurus/docs/actions/core/platform/ask-platform-get-name.md +32 -0
- package/template/docusaurus/docs/actions/webserver/email/ask-email-parse.md +54 -0
- package/template/docusaurus/docs/config/config-aws/account-email-receiving.md +33 -0
- package/template/docusaurus/docs/config/core/crypto-key.md +3 -1
- package/template/docusaurus/docs/config/core/key-value-store.md +4 -3
- package/template/docusaurus/docs/config/core/storage-drive.md +5 -4
- package/template/docusaurus/docs/config/features/event-doc-routes.md +1 -0
- package/template/docusaurus/docs/config/features/tenant-stores.md +10 -5
- package/template/docusaurus/docs/config/features/tenant.md +36 -12
- package/template/docusaurus/docs/config/features/tenanted-email-receiver.md +79 -0
- package/template/docusaurus/docs/config/features/tenanted-event-doc.md +22 -7
- package/template/docusaurus/docs/config/webserver/email-receiver.md +65 -0
- package/template/docusaurus/docs/config/webserver/email-receiving-domain.md +41 -0
- package/template/scripts/smoke/runSmokeRun.mjs +1 -1
- 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
|
-
| `
|
|
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
|
-
|
|
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:** [
|
|
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
|
-
| `
|
|
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
|
-
|
|
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:** [
|
|
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:
|
|
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:
|
|
65
|
+
tenantId: TenantId;
|
|
66
66
|
userId: string;
|
|
67
|
-
|
|
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
|
-
`
|
|
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 `
|
|
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
|
|
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
|
|
42
|
-
| `POST` | `{myTenantsBasePath}` | Create a tenant (body `{ name }`); the caller becomes its first member.
|
|
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
|
|
45
|
-
| `GET` | `{myTenantsBasePath}/{id}/
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
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), `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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') {
|