create-kumiko-app 0.367.0 → 0.369.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/feature-manifest.json +16 -16
- package/package.json +5 -5
- package/src/feature-constructors.ts +22 -27
package/feature-manifest.json
CHANGED
|
@@ -407,7 +407,7 @@
|
|
|
407
407
|
},
|
|
408
408
|
{
|
|
409
409
|
"name": "channel-email",
|
|
410
|
-
"description": "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send
|
|
410
|
+
"description": "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set; a mail that then has no transport fails with `missing_credentials`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
|
|
411
411
|
"toggleableDefault": null,
|
|
412
412
|
"requires": [
|
|
413
413
|
"delivery"
|
|
@@ -725,7 +725,7 @@
|
|
|
725
725
|
},
|
|
726
726
|
{
|
|
727
727
|
"name": "delivery",
|
|
728
|
-
"description": "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
|
|
728
|
+
"description": "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (the `runProdApp` and `runDevApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
|
|
729
729
|
"toggleableDefault": null,
|
|
730
730
|
"requires": [
|
|
731
731
|
"tenant"
|
|
@@ -1507,7 +1507,7 @@
|
|
|
1507
1507
|
},
|
|
1508
1508
|
{
|
|
1509
1509
|
"name": "mail-foundation",
|
|
1510
|
-
"description": "Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature`
|
|
1510
|
+
"description": "Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature`: notifications declared with `tenantTransport: true` then go through the tenant's own transport when it has a provider and through the app transport otherwise. Tenant admins pick the provider and fill in its settings on the generated Email sending settings screen; an empty provider is a valid choice and means the platform sends.",
|
|
1511
1511
|
"toggleableDefault": null,
|
|
1512
1512
|
"requires": [
|
|
1513
1513
|
"config"
|
|
@@ -1531,8 +1531,8 @@
|
|
|
1531
1531
|
"bounds": null,
|
|
1532
1532
|
"pattern": null,
|
|
1533
1533
|
"writeRoles": [
|
|
1534
|
-
"
|
|
1535
|
-
"
|
|
1534
|
+
"system",
|
|
1535
|
+
"TenantAdmin"
|
|
1536
1536
|
],
|
|
1537
1537
|
"readRoles": [
|
|
1538
1538
|
"TenantAdmin",
|
|
@@ -1603,8 +1603,8 @@
|
|
|
1603
1603
|
"bounds": null,
|
|
1604
1604
|
"pattern": null,
|
|
1605
1605
|
"writeRoles": [
|
|
1606
|
-
"
|
|
1607
|
-
"
|
|
1606
|
+
"system",
|
|
1607
|
+
"TenantAdmin"
|
|
1608
1608
|
],
|
|
1609
1609
|
"readRoles": [
|
|
1610
1610
|
"TenantAdmin",
|
|
@@ -1624,8 +1624,8 @@
|
|
|
1624
1624
|
"bounds": null,
|
|
1625
1625
|
"pattern": null,
|
|
1626
1626
|
"writeRoles": [
|
|
1627
|
-
"
|
|
1628
|
-
"
|
|
1627
|
+
"system",
|
|
1628
|
+
"TenantAdmin"
|
|
1629
1629
|
],
|
|
1630
1630
|
"readRoles": [
|
|
1631
1631
|
"TenantAdmin",
|
|
@@ -1645,8 +1645,8 @@
|
|
|
1645
1645
|
"bounds": null,
|
|
1646
1646
|
"pattern": null,
|
|
1647
1647
|
"writeRoles": [
|
|
1648
|
-
"
|
|
1649
|
-
"
|
|
1648
|
+
"system",
|
|
1649
|
+
"TenantAdmin"
|
|
1650
1650
|
],
|
|
1651
1651
|
"readRoles": [
|
|
1652
1652
|
"TenantAdmin",
|
|
@@ -1669,8 +1669,8 @@
|
|
|
1669
1669
|
},
|
|
1670
1670
|
"pattern": null,
|
|
1671
1671
|
"writeRoles": [
|
|
1672
|
-
"
|
|
1673
|
-
"
|
|
1672
|
+
"system",
|
|
1673
|
+
"TenantAdmin"
|
|
1674
1674
|
],
|
|
1675
1675
|
"readRoles": [
|
|
1676
1676
|
"all"
|
|
@@ -1689,8 +1689,8 @@
|
|
|
1689
1689
|
"bounds": null,
|
|
1690
1690
|
"pattern": null,
|
|
1691
1691
|
"writeRoles": [
|
|
1692
|
-
"
|
|
1693
|
-
"
|
|
1692
|
+
"system",
|
|
1693
|
+
"TenantAdmin"
|
|
1694
1694
|
],
|
|
1695
1695
|
"readRoles": [
|
|
1696
1696
|
"all"
|
|
@@ -2191,7 +2191,7 @@
|
|
|
2191
2191
|
},
|
|
2192
2192
|
{
|
|
2193
2193
|
"name": "secrets",
|
|
2194
|
-
"description": "Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: [\"TenantAdmin\"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins).",
|
|
2194
|
+
"description": "Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: [\"TenantAdmin\"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins). Server-side code writes a tenant secret with `dispatchSystemWrite({ handlerQn: \"secrets:write:set\", payload, tenantId })`: set and delete also admit the in-process system identity, so such writes pass the same declared-key and value checks.",
|
|
2195
2195
|
"toggleableDefault": null,
|
|
2196
2196
|
"requires": [],
|
|
2197
2197
|
"optionalRequires": [],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-kumiko-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.369.0",
|
|
4
4
|
"description": "`bun create kumiko-app <name>` — scaffold a new Kumiko app with an interactive feature picker.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
31
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
32
|
-
"@cosmicdrift/kumiko-server-runtime": "0.
|
|
33
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
30
|
+
"@cosmicdrift/kumiko-dev-server": "0.369.0",
|
|
31
|
+
"@cosmicdrift/kumiko-framework": "0.369.0",
|
|
32
|
+
"@cosmicdrift/kumiko-server-runtime": "0.369.0",
|
|
33
|
+
"@cosmicdrift/kumiko-testing": "0.369.0",
|
|
34
34
|
"@inquirer/core": "^10.0.0",
|
|
35
35
|
"@inquirer/prompts": "^7.4.0"
|
|
36
36
|
},
|
|
@@ -7,11 +7,6 @@
|
|
|
7
7
|
// subscription-mollie, file-provider-s3, managed-pages, tier-engine) are
|
|
8
8
|
// intentionally absent — the picker hides them, the user wires them by
|
|
9
9
|
// hand after scaffold.
|
|
10
|
-
//
|
|
11
|
-
// Naming inconsistency is intentional and historical: most features expose
|
|
12
|
-
// `create<Pascal>Feature` factories, a handful export the FeatureDefinition
|
|
13
|
-
// object directly. The callExpression captures that: `()` for factories,
|
|
14
|
-
// bare name for objects.
|
|
15
10
|
|
|
16
11
|
import type { ScaffoldFeatureEntry } from "@cosmicdrift/kumiko-dev-server";
|
|
17
12
|
|
|
@@ -32,8 +27,8 @@ export const FEATURE_CONSTRUCTORS: Readonly<Record<string, ScaffoldFeatureEntry>
|
|
|
32
27
|
"auth-foundation": {
|
|
33
28
|
name: "auth-foundation",
|
|
34
29
|
importPath: "@cosmicdrift/kumiko-bundled-features/auth-foundation",
|
|
35
|
-
exportName: "
|
|
36
|
-
callExpression: "
|
|
30
|
+
exportName: "createAuthFoundationFeature",
|
|
31
|
+
callExpression: "createAuthFoundationFeature()",
|
|
37
32
|
},
|
|
38
33
|
"personal-access-tokens": {
|
|
39
34
|
name: "personal-access-tokens",
|
|
@@ -85,8 +80,8 @@ export const FEATURE_CONSTRUCTORS: Readonly<Record<string, ScaffoldFeatureEntry>
|
|
|
85
80
|
"cap-counter": {
|
|
86
81
|
name: "cap-counter",
|
|
87
82
|
importPath: "@cosmicdrift/kumiko-bundled-features/cap-counter",
|
|
88
|
-
exportName: "
|
|
89
|
-
callExpression: "
|
|
83
|
+
exportName: "createCapCounterFeature",
|
|
84
|
+
callExpression: "createCapCounterFeature()",
|
|
90
85
|
},
|
|
91
86
|
"step-dispatcher": {
|
|
92
87
|
name: "step-dispatcher",
|
|
@@ -105,14 +100,14 @@ export const FEATURE_CONSTRUCTORS: Readonly<Record<string, ScaffoldFeatureEntry>
|
|
|
105
100
|
"file-foundation": {
|
|
106
101
|
name: "file-foundation",
|
|
107
102
|
importPath: "@cosmicdrift/kumiko-bundled-features/file-foundation",
|
|
108
|
-
exportName: "
|
|
109
|
-
callExpression: "
|
|
103
|
+
exportName: "createFileFoundationFeature",
|
|
104
|
+
callExpression: "createFileFoundationFeature()",
|
|
110
105
|
},
|
|
111
106
|
"file-provider-inmemory": {
|
|
112
107
|
name: "file-provider-inmemory",
|
|
113
108
|
importPath: "@cosmicdrift/kumiko-bundled-features/file-provider-inmemory",
|
|
114
|
-
exportName: "
|
|
115
|
-
callExpression: "
|
|
109
|
+
exportName: "createFileProviderInMemoryFeature",
|
|
110
|
+
callExpression: "createFileProviderInMemoryFeature()",
|
|
116
111
|
},
|
|
117
112
|
|
|
118
113
|
// --- Notifications ---
|
|
@@ -125,20 +120,20 @@ export const FEATURE_CONSTRUCTORS: Readonly<Record<string, ScaffoldFeatureEntry>
|
|
|
125
120
|
"mail-foundation": {
|
|
126
121
|
name: "mail-foundation",
|
|
127
122
|
importPath: "@cosmicdrift/kumiko-bundled-features/mail-foundation",
|
|
128
|
-
exportName: "
|
|
129
|
-
callExpression: "
|
|
123
|
+
exportName: "createMailFoundationFeature",
|
|
124
|
+
callExpression: "createMailFoundationFeature()",
|
|
130
125
|
},
|
|
131
126
|
"mail-transport-inmemory": {
|
|
132
127
|
name: "mail-transport-inmemory",
|
|
133
128
|
importPath: "@cosmicdrift/kumiko-bundled-features/mail-transport-inmemory",
|
|
134
|
-
exportName: "
|
|
135
|
-
callExpression: "
|
|
129
|
+
exportName: "createMailTransportInMemoryFeature",
|
|
130
|
+
callExpression: "createMailTransportInMemoryFeature()",
|
|
136
131
|
},
|
|
137
132
|
"mail-transport-smtp": {
|
|
138
133
|
name: "mail-transport-smtp",
|
|
139
134
|
importPath: "@cosmicdrift/kumiko-bundled-features/mail-transport-smtp",
|
|
140
|
-
exportName: "
|
|
141
|
-
callExpression: "
|
|
135
|
+
exportName: "createMailTransportSmtpFeature",
|
|
136
|
+
callExpression: "createMailTransportSmtpFeature()",
|
|
142
137
|
},
|
|
143
138
|
"channel-in-app": {
|
|
144
139
|
name: "channel-in-app",
|
|
@@ -163,8 +158,8 @@ export const FEATURE_CONSTRUCTORS: Readonly<Record<string, ScaffoldFeatureEntry>
|
|
|
163
158
|
"billing-foundation": {
|
|
164
159
|
name: "billing-foundation",
|
|
165
160
|
importPath: "@cosmicdrift/kumiko-bundled-features/billing-foundation",
|
|
166
|
-
exportName: "
|
|
167
|
-
callExpression: "
|
|
161
|
+
exportName: "createBillingFoundationFeature",
|
|
162
|
+
callExpression: "createBillingFoundationFeature()",
|
|
168
163
|
},
|
|
169
164
|
|
|
170
165
|
// --- Compliance ---
|
|
@@ -221,8 +216,8 @@ export const FEATURE_CONSTRUCTORS: Readonly<Record<string, ScaffoldFeatureEntry>
|
|
|
221
216
|
readiness: {
|
|
222
217
|
name: "readiness",
|
|
223
218
|
importPath: "@cosmicdrift/kumiko-bundled-features/readiness",
|
|
224
|
-
exportName: "
|
|
225
|
-
callExpression: "
|
|
219
|
+
exportName: "createReadinessFeature",
|
|
220
|
+
callExpression: "createReadinessFeature()",
|
|
226
221
|
},
|
|
227
222
|
|
|
228
223
|
// --- Content ---
|
|
@@ -237,14 +232,14 @@ export const FEATURE_CONSTRUCTORS: Readonly<Record<string, ScaffoldFeatureEntry>
|
|
|
237
232
|
tags: {
|
|
238
233
|
name: "tags",
|
|
239
234
|
importPath: "@cosmicdrift/kumiko-bundled-features/tags",
|
|
240
|
-
exportName: "
|
|
241
|
-
callExpression: "
|
|
235
|
+
exportName: "createTagsFeature",
|
|
236
|
+
callExpression: "createTagsFeature()",
|
|
242
237
|
},
|
|
243
238
|
"custom-fields": {
|
|
244
239
|
name: "custom-fields",
|
|
245
240
|
importPath: "@cosmicdrift/kumiko-bundled-features/custom-fields",
|
|
246
|
-
exportName: "
|
|
247
|
-
callExpression: "
|
|
241
|
+
exportName: "createCustomFieldsFeature",
|
|
242
|
+
callExpression: "createCustomFieldsFeature()",
|
|
248
243
|
},
|
|
249
244
|
};
|
|
250
245
|
|