@cosmicdrift/kumiko-samples 0.344.0 → 0.346.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/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/constants.ts +5 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/handlers/details.query.ts +14 -3
- package/packages/bundled-features/src/audit/handlers/list.query.ts +11 -2
- package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +22 -0
- package/packages/bundled-features/src/audit/i18n.ts +3 -0
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +31 -12
- package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +9 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +33 -10
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +32 -15
- package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +60 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -1
- package/packages/bundled-features/src/auth-email-password/i18n.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +20 -7
- package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +31 -11
- package/packages/bundled-features/src/step-dispatcher/changes.json +31 -0
- package/packages/bundled-features/src/step-dispatcher/dispatch-payload.ts +144 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +32 -152
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +30 -3
- package/packages/bundled-features/src/tier-engine/constants.ts +7 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +2 -4
- package/packages/bundled-features/src/tier-engine/feature.ts +7 -2
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +10 -4
- package/packages/bundled-features/src/tier-engine/index.ts +6 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +3 -2
- package/samples/recipes/i18n/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +1 -1
- package/samples/recipes/webhook-step/README.md +9 -2
- package/samples/recipes/webhook-step/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.346.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.346.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.346.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Audit queries can read app-instance system events with scope \"system\" (SystemAdmin only)",
|
|
6
|
+
"detail": "Audit queries accept `scope: \"system\"` for SystemAdmin\n`audit:query:list` and `audit:query:details` take an optional `scope` (`\"tenant\"` or `\"system\"`). With `\"system\"` a SystemAdmin reads the app-instance system events, e.g. `kumiko:system:app.started`; other roles are denied. Without `scope` nothing changes."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.345.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Escape-hatch retention loads only expirable rows",
|
|
12
|
+
"detail": "Escape-hatch audit retention loads only rows that can expire\nThe retention job used to load every escapeHatchUse event to decide which ones to prune. It now reads the storing tenants with one grouped query and loads only events older than each storing tenant's cutoff. The same rows are pruned as before."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.343.0",
|
|
4
16
|
"type": "improvement",
|
|
@@ -19,6 +19,11 @@ export const AuditQueries = {
|
|
|
19
19
|
details: "audit:query:details",
|
|
20
20
|
} as const;
|
|
21
21
|
|
|
22
|
+
/** Read scope of the audit queries: the caller's own tenant (default) or the system tenant (SystemAdmin only). */
|
|
23
|
+
export const AuditScopes = { tenant: "tenant", system: "system" } as const;
|
|
24
|
+
export type AuditScope = (typeof AuditScopes)[keyof typeof AuditScopes];
|
|
25
|
+
export const AUDIT_SCOPE_VALUES = [AuditScopes.tenant, AuditScopes.system] as const;
|
|
26
|
+
|
|
22
27
|
/** Tenant-admin audit log screen. Nav: `audit:screen:audit-log`. */
|
|
23
28
|
export const AUDIT_LOG_SCREEN_ID = "audit-log" as const;
|
|
24
29
|
|
|
@@ -36,7 +36,7 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
36
36
|
"audit",
|
|
37
37
|
(r) => {
|
|
38
38
|
r.describe(
|
|
39
|
-
"Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).",
|
|
39
|
+
"Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. A SystemAdmin can pass `scope: \"system\"` to read the app-instance system events such as `app.started`. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).",
|
|
40
40
|
);
|
|
41
41
|
r.uiHints({
|
|
42
42
|
displayLabel: "Audit Log",
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
// Single audit event by its event-store id — backs the audit-log-detail
|
|
2
2
|
// screen. Tenant-isolated at the WHERE level like list.query, so a caller
|
|
3
|
-
// can only read events in their own tenant
|
|
3
|
+
// can only read events in their own tenant; a SystemAdmin can pass scope
|
|
4
|
+
// "system" to read app-instance system events (e.g. app.started).
|
|
4
5
|
|
|
5
6
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
7
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
8
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
9
|
import * as z from "zod";
|
|
10
|
+
import { AUDIT_SCOPE_VALUES } from "../constants.js";
|
|
11
|
+
import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
|
|
9
12
|
|
|
10
13
|
export const detailsQuery = defineQueryHandler({
|
|
11
14
|
name: "details",
|
|
12
15
|
description:
|
|
13
|
-
|
|
16
|
+
'Returns one audit-trail event of the caller\'s tenant by its event-store id, with full payload and metadata; use it to inspect the exact change behind a row of the audit log list. A SystemAdmin can set scope "system" to read an app-instance system event (e.g. app.started).',
|
|
14
17
|
schema: z.object({
|
|
15
18
|
id: z.string().regex(/^[1-9]\d*$/, "id must be a positive integer"),
|
|
19
|
+
scope: z.enum(AUDIT_SCOPE_VALUES).optional(),
|
|
16
20
|
}),
|
|
17
21
|
access: { roles: access.admin },
|
|
18
22
|
handler: async (query, ctx) => {
|
|
23
|
+
const scopeFilter = resolveAuditScopeFilter(query.user, query.payload.scope);
|
|
19
24
|
const rows = await selectMany<{
|
|
20
25
|
id: bigint;
|
|
21
26
|
aggregateId: string;
|
|
@@ -29,7 +34,13 @@ export const detailsQuery = defineQueryHandler({
|
|
|
29
34
|
}>(
|
|
30
35
|
ctx.db,
|
|
31
36
|
eventsTable,
|
|
32
|
-
{
|
|
37
|
+
{
|
|
38
|
+
tenantId: scopeFilter.tenantId,
|
|
39
|
+
...(scopeFilter.aggregateType !== undefined && {
|
|
40
|
+
aggregateType: scopeFilter.aggregateType,
|
|
41
|
+
}),
|
|
42
|
+
id: BigInt(query.payload.id),
|
|
43
|
+
},
|
|
33
44
|
{ limit: 1 },
|
|
34
45
|
);
|
|
35
46
|
const row = rows[0];
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// No projection, no separate audit table. Queryable with the same filter
|
|
7
7
|
// surface any audit UI needs; tenant-isolated at the WHERE level so cross-
|
|
8
8
|
// tenant peeking is structurally impossible for non-SystemAdmin callers.
|
|
9
|
+
// A SystemAdmin can pass scope "system" to read the app-instance system events
|
|
10
|
+
// (e.g. app.started); no other cross-tenant read exists.
|
|
9
11
|
//
|
|
10
12
|
// Sensitive field-values are ciphertext inside the event payload (the log
|
|
11
13
|
// carries them encrypted); stripSensitive only strips the event echo. This
|
|
@@ -17,6 +19,8 @@ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine
|
|
|
17
19
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
20
|
import { Temporal } from "temporal-polyfill";
|
|
19
21
|
import * as z from "zod";
|
|
22
|
+
import { AUDIT_SCOPE_VALUES } from "../constants.js";
|
|
23
|
+
import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
|
|
20
24
|
|
|
21
25
|
const MAX_LIMIT = 100;
|
|
22
26
|
|
|
@@ -65,7 +69,7 @@ function buildAuditWhere(
|
|
|
65
69
|
export const listQuery = defineQueryHandler({
|
|
66
70
|
name: "list",
|
|
67
71
|
description:
|
|
68
|
-
|
|
72
|
+
'Lists the tenant\'s audit-trail events newest-first with cursor paging, filterable by aggregate type, aggregate id, event type, actor and time range; use it to answer who changed what and when. A SystemAdmin can set scope "system" to read the app-instance system events (e.g. app.started) instead of the own tenant.',
|
|
69
73
|
schema: z
|
|
70
74
|
.object({
|
|
71
75
|
cursor: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
|
|
@@ -80,6 +84,7 @@ export const listQuery = defineQueryHandler({
|
|
|
80
84
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
81
85
|
from: z.iso.datetime().optional(),
|
|
82
86
|
to: z.iso.datetime().optional(),
|
|
87
|
+
scope: z.enum(AUDIT_SCOPE_VALUES).optional(),
|
|
83
88
|
})
|
|
84
89
|
.refine((v) => !v.from || !v.to || v.from <= v.to, {
|
|
85
90
|
message: "`from` must be less than or equal to `to`",
|
|
@@ -88,7 +93,11 @@ export const listQuery = defineQueryHandler({
|
|
|
88
93
|
access: { roles: access.admin },
|
|
89
94
|
handler: async (query, ctx) => {
|
|
90
95
|
const p = query.payload;
|
|
91
|
-
const
|
|
96
|
+
const scopeFilter = resolveAuditScopeFilter(query.user, p.scope);
|
|
97
|
+
const where = buildAuditWhere(scopeFilter.tenantId, {
|
|
98
|
+
...p,
|
|
99
|
+
aggregateType: scopeFilter.aggregateType ?? p.aggregateType,
|
|
100
|
+
});
|
|
92
101
|
|
|
93
102
|
const rows = await selectMany<{
|
|
94
103
|
id: bigint;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { crossTenantOverrideDenied, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { APP_INSTANCE_STREAM_TYPE } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
4
|
+
import { type AuditScope, AuditScopes } from "../constants.js";
|
|
5
|
+
|
|
6
|
+
export type AuditScopeFilter = { readonly tenantId: string; readonly aggregateType?: string };
|
|
7
|
+
|
|
8
|
+
// The system tenant also holds personal streams of other features (session
|
|
9
|
+
// revocations, exports, ...); only app-instance streams are released.
|
|
10
|
+
export function resolveAuditScopeFilter(
|
|
11
|
+
user: SessionUser,
|
|
12
|
+
scope: AuditScope | undefined,
|
|
13
|
+
): AuditScopeFilter {
|
|
14
|
+
if (scope !== AuditScopes.system) return { tenantId: user.tenantId };
|
|
15
|
+
const denied = crossTenantOverrideDenied(
|
|
16
|
+
user,
|
|
17
|
+
SYSTEM_TENANT_ID,
|
|
18
|
+
"audit.errors.systemScopeRequiresSystemAdmin",
|
|
19
|
+
);
|
|
20
|
+
if (denied) throw denied;
|
|
21
|
+
return { tenantId: SYSTEM_TENANT_ID, aggregateType: APP_INSTANCE_STREAM_TYPE };
|
|
22
|
+
}
|
|
@@ -9,6 +9,9 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
9
9
|
"audit.log.detail.subtitle": {
|
|
10
10
|
en: "Read-only detail view of one audit event showing actor, timestamp, aggregate and the raw event payload and metadata; reached from a row of the audit log.",
|
|
11
11
|
},
|
|
12
|
+
"audit.errors.systemScopeRequiresSystemAdmin": {
|
|
13
|
+
en: "Only SystemAdmin may read system events.",
|
|
14
|
+
},
|
|
12
15
|
"audit:nav.auditLog": { en: "Audit" },
|
|
13
16
|
"audit.log.col.when": { en: "When" },
|
|
14
17
|
"audit.log.col.type": { en: "Event" },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
|
+
import { aggregateWhere, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import {
|
|
3
3
|
COMPLIANCE_PROFILES,
|
|
4
4
|
subtractRetentionSpec,
|
|
@@ -82,10 +82,20 @@ function targetTenantOf(payload: unknown): TenantId | null {
|
|
|
82
82
|
return parsed.success ? parseTenantId(parsed.data.targetTenantId) : null;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
async function
|
|
86
|
-
const
|
|
87
|
-
|
|
85
|
+
async function storingTenantIds(db: DbConnection): Promise<readonly TenantId[]> {
|
|
86
|
+
const groups = await aggregateWhere(
|
|
87
|
+
db,
|
|
88
|
+
eventsTable,
|
|
89
|
+
{ measure: { fn: "count" }, groupBy: [{ field: "tenantId" }] },
|
|
90
|
+
{ aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE },
|
|
91
|
+
);
|
|
92
|
+
return groups.flatMap((group) => {
|
|
93
|
+
const tenantId = parseTenantId(group.keys[0]);
|
|
94
|
+
return tenantId ? [tenantId] : [];
|
|
88
95
|
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function pruneByTenantProfiles(db: DbConnection): Promise<number> {
|
|
89
99
|
const now = getTemporal().Now.instant();
|
|
90
100
|
const cutoffByTenant = new Map<TenantId, Temporal.Instant>();
|
|
91
101
|
const cutoffOf = async (tenantId: TenantId): Promise<Temporal.Instant> => {
|
|
@@ -96,15 +106,24 @@ async function pruneByTenantProfiles(db: DbConnection): Promise<number> {
|
|
|
96
106
|
return cutoff;
|
|
97
107
|
};
|
|
98
108
|
|
|
109
|
+
// The effective cutoff is never later than the storing tenant's, so only rows older than
|
|
110
|
+
// that one can expire; everything younger stays in the database.
|
|
99
111
|
const expiredAggregateIds: string[] = [];
|
|
100
|
-
for (const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
: storeCutoff
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
for (const storingTenantId of await storingTenantIds(db)) {
|
|
113
|
+
const storeCutoff = await cutoffOf(storingTenantId);
|
|
114
|
+
const candidates = await selectMany<AuditEventRow>(db, eventsTable, {
|
|
115
|
+
aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
|
|
116
|
+
tenantId: storingTenantId,
|
|
117
|
+
createdAt: { lt: storeCutoff },
|
|
118
|
+
});
|
|
119
|
+
for (const row of candidates) {
|
|
120
|
+
const targetTenantId = targetTenantOf(row.payload);
|
|
121
|
+
const cutoff = targetTenantId
|
|
122
|
+
? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
|
|
123
|
+
: storeCutoff;
|
|
124
|
+
if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
|
|
125
|
+
expiredAggregateIds.push(row.aggregateId);
|
|
126
|
+
}
|
|
108
127
|
}
|
|
109
128
|
}
|
|
110
129
|
|
|
@@ -1,4 +1,28 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.345.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "invite-signup-complete enforces the MFA gate before issuing a session",
|
|
6
|
+
"detail": "`invite-signup-complete` now applies the same MFA gate as `invite-accept-with-login`. The account is still created and the invitation accepted, but an invitee whose role requires MFA (for example an admin invitation under the `admins` policy) receives the MFA step instead of a session. Member invitations keep receiving a session directly."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.345.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "signup-confirm enforces the MFA gate before issuing a session",
|
|
12
|
+
"detail": "`POST /auth/signup-confirm` now runs the login's MFA gate before it issues the first session. Self-signup makes the new user TenantAdmin of a fresh tenant, so under an MFA policy that covers admins the route used to hand out an admin session without a second factor. The account, the tenant and a bound handover claim are still created, but when the gate applies the route answers with the login contract (`mfaSetupRequired` plus `preauthSetupToken`, or `mfaRequired` plus `challengeToken`) and sets no cookies. `SignupCompleteScreen` then tells the user the account is active and sends them to sign in, where they set up the second factor. Without `mfaStatusChecker` nothing changes. Two exported types change shape: `SignupConfirmData` gains the two MFA variants, and `confirmSignup` now resolves to `SignupConfirmResult` with `kind: \"signed-in\"` (the previous `SignupConfirmSuccess` fields) or `kind: \"mfa-pending\"`. The handler and `invite-signup-complete` also run the gate before they delete the token, so an error in the MFA check leaves the link usable for a retry."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.345.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "switch-tenant enforces the MFA gate before issuing a session",
|
|
18
|
+
"detail": "`POST /auth/switch-tenant` now runs the same MFA gate as login before it issues the new session. A user who is a plain member in one tenant and an admin in another can no longer reach an admin session without a second factor by switching. When the target tenant requires MFA the route answers with the login contract (`mfaRequired` plus `challengeToken`, or `mfaSetupRequired` plus `preauthSetupToken`) and sets no cookies. The gate runs in a new system-only handler, `auth-email-password:write:switch-tenant-mfa-gate`, which resolves the membership and roles itself; `runDevApp` and `runProdApp` wire it as `switchTenantMfaGateHandler` whenever auth-mfa is mounted."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.345.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "Token mail expiry uses the recipient's profile time zone",
|
|
24
|
+
"detail": "Token mails show the expiry in the recipient's own time zone\nPassword reset, email verification and account unlock are requested anonymously, so `ctx.tz.user` only held the tenant default or UTC. These mails now use the recipient's profile time zone when it is a valid IANA zone and fall back to the previous value otherwise. Signup and invite mails keep the fallback because the recipient has no profile yet."
|
|
25
|
+
},
|
|
2
26
|
{
|
|
3
27
|
"version": "0.343.0",
|
|
4
28
|
"type": "improvement",
|
|
@@ -46,6 +46,7 @@ export const AuthHandlers = {
|
|
|
46
46
|
inviteAccept: "auth-email-password:write:invite-accept",
|
|
47
47
|
inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
|
|
48
48
|
inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
|
|
49
|
+
switchTenantMfaGate: "auth-email-password:write:switch-tenant-mfa-gate",
|
|
49
50
|
inviteCancel: "auth-email-password:write:invite-cancel",
|
|
50
51
|
} as const;
|
|
51
52
|
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
createSignupRequestHandler,
|
|
29
29
|
type SignupRequestOptions,
|
|
30
30
|
} from "./handlers/signup-request.write.js";
|
|
31
|
+
import { createSwitchTenantMfaGateHandler } from "./handlers/switch-tenant-mfa-gate.write.js";
|
|
31
32
|
import { createVerifyEmailHandler } from "./handlers/verify-email.write.js";
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -221,6 +222,10 @@ export function createAuthEmailPasswordFeature(
|
|
|
221
222
|
logout: r.writeHandler(logoutWrite),
|
|
222
223
|
};
|
|
223
224
|
|
|
225
|
+
if (opts.mfaStatusChecker) {
|
|
226
|
+
r.writeHandler(createSwitchTenantMfaGateHandler({ mfaStatusChecker: opts.mfaStatusChecker }));
|
|
227
|
+
}
|
|
228
|
+
|
|
224
229
|
if (opts.passwordReset) {
|
|
225
230
|
r.writeHandler(createRequestPasswordResetHandler(opts.passwordReset));
|
|
226
231
|
r.writeHandler(createResetPasswordHandler(opts.passwordReset));
|
|
@@ -242,7 +247,7 @@ export function createAuthEmailPasswordFeature(
|
|
|
242
247
|
|
|
243
248
|
if (opts.signup) {
|
|
244
249
|
r.writeHandler(createSignupRequestHandler(opts.signup));
|
|
245
|
-
r.writeHandler(createSignupConfirmHandler());
|
|
250
|
+
r.writeHandler(createSignupConfirmHandler({ mfaStatusChecker: opts.mfaStatusChecker }));
|
|
246
251
|
}
|
|
247
252
|
|
|
248
253
|
if (opts.invite) {
|
|
@@ -258,7 +263,9 @@ export function createAuthEmailPasswordFeature(
|
|
|
258
263
|
strictEmailVerification: strictVerification,
|
|
259
264
|
}),
|
|
260
265
|
);
|
|
261
|
-
r.writeHandler(
|
|
266
|
+
r.writeHandler(
|
|
267
|
+
createInviteSignupCompleteHandler({ mfaStatusChecker: opts.mfaStatusChecker }),
|
|
268
|
+
);
|
|
262
269
|
}
|
|
263
270
|
|
|
264
271
|
if (opts.accountUnlock) {
|
package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts
CHANGED
|
@@ -60,18 +60,23 @@ import {
|
|
|
60
60
|
import { passwordSchema } from "../password-policy.js";
|
|
61
61
|
// kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
|
|
62
62
|
import { seedUserWithPassword } from "../seeding.js";
|
|
63
|
+
import { gateEnforceMfa, type LoginHandlerOptions, type LoginResult } from "./login.write.js";
|
|
63
64
|
|
|
64
65
|
const InviteSignupCompleteSchema = z.object({
|
|
65
66
|
token: z.string().min(1),
|
|
66
67
|
password: passwordSchema,
|
|
67
68
|
});
|
|
68
69
|
|
|
69
|
-
export type InviteSignupCompleteData =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
export type InviteSignupCompleteData =
|
|
71
|
+
| {
|
|
72
|
+
readonly kind: "auth-session";
|
|
73
|
+
readonly session: SessionUser;
|
|
74
|
+
readonly tenantId: TenantId;
|
|
75
|
+
readonly role: string;
|
|
76
|
+
}
|
|
77
|
+
| Exclude<LoginResult, { readonly kind: "auth-session" }>;
|
|
78
|
+
|
|
79
|
+
export type InviteSignupCompleteOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
|
|
75
80
|
|
|
76
81
|
const invitationExecutor = createEventStoreExecutor(
|
|
77
82
|
tenantInvitationsTable,
|
|
@@ -82,7 +87,7 @@ const invitationExecutor = createEventStoreExecutor(
|
|
|
82
87
|
const INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON =
|
|
83
88
|
"reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant. Creates the user and adds the membership and accepts the invitation in the invitation's tenant, before any caller tenant context exists.";
|
|
84
89
|
|
|
85
|
-
export function createInviteSignupCompleteHandler() {
|
|
90
|
+
export function createInviteSignupCompleteHandler(opts: InviteSignupCompleteOptions = {}) {
|
|
86
91
|
return defineWriteHandler<
|
|
87
92
|
"invite-signup-complete",
|
|
88
93
|
typeof InviteSignupCompleteSchema,
|
|
@@ -190,14 +195,32 @@ export function createInviteSignupCompleteHandler() {
|
|
|
190
195
|
);
|
|
191
196
|
if (!updateResult.isSuccess) return updateResult;
|
|
192
197
|
|
|
198
|
+
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
199
|
+
// a reserved role on the invitation itself must never reach the session.
|
|
200
|
+
const mergedRoles = buildSessionRoles(invitationGlobalRoles, [invitationRole]);
|
|
201
|
+
|
|
202
|
+
// Same gate as invite-accept-with-login: the account exists after this
|
|
203
|
+
// call, but an MFA-gated role gets the MFA step instead of a session.
|
|
204
|
+
// It runs before the token is deleted so a throwing check leaves the link retryable.
|
|
205
|
+
const mfaGate = await gateEnforceMfa(
|
|
206
|
+
ctx,
|
|
207
|
+
{ mfaStatusChecker: opts.mfaStatusChecker },
|
|
208
|
+
userId,
|
|
209
|
+
invitationTenantId,
|
|
210
|
+
mergedRoles,
|
|
211
|
+
);
|
|
212
|
+
|
|
193
213
|
await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
|
|
194
214
|
|
|
215
|
+
if (mfaGate !== undefined) {
|
|
216
|
+
committed = true;
|
|
217
|
+
return { isSuccess: true, data: mfaGate };
|
|
218
|
+
}
|
|
219
|
+
|
|
195
220
|
const session: SessionUser = {
|
|
196
221
|
id: userId,
|
|
197
222
|
tenantId: invitationTenantId,
|
|
198
|
-
|
|
199
|
-
// a reserved role on the invitation itself must never reach the session.
|
|
200
|
-
roles: buildSessionRoles(invitationGlobalRoles, [invitationRole]),
|
|
223
|
+
roles: mergedRoles,
|
|
201
224
|
};
|
|
202
225
|
|
|
203
226
|
committed = true;
|
|
@@ -56,26 +56,29 @@ import {
|
|
|
56
56
|
getSignupHandover,
|
|
57
57
|
unburnSignupToken,
|
|
58
58
|
} from "../signup-token-store.js";
|
|
59
|
+
import { gateEnforceMfa, type LoginHandlerOptions, type LoginResult } from "./login.write.js";
|
|
59
60
|
|
|
60
61
|
const SignupConfirmSchema = z.object({
|
|
61
62
|
token: z.string().min(8),
|
|
62
63
|
password: passwordSchema,
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
66
|
+
// Mirrors the login handler's session shape so the route treats a successful
|
|
67
|
+
// confirm like a login. tenantKey stays a sibling of the session: it is only
|
|
68
|
+
// for the post-signup redirect, SessionUser is generic. When the MFA gate
|
|
69
|
+
// applies, the MFA step is returned instead of a session.
|
|
70
|
+
export type SignupConfirmData =
|
|
71
|
+
| {
|
|
72
|
+
readonly kind: "auth-session";
|
|
73
|
+
readonly session: SessionUser;
|
|
74
|
+
readonly tenantKey: string;
|
|
75
|
+
// Present only when a bound tenant-handover grant existed AND its claim
|
|
76
|
+
// actually succeeded (see the handler body below).
|
|
77
|
+
readonly handover?: { readonly entityType: string; readonly id: string };
|
|
78
|
+
}
|
|
79
|
+
| Exclude<LoginResult, { readonly kind: "auth-session" }>;
|
|
80
|
+
|
|
81
|
+
export type SignupConfirmOptions = Pick<LoginHandlerOptions, "mfaStatusChecker">;
|
|
79
82
|
|
|
80
83
|
const SIGNUP_CONFIRM_PROVISION_REASON =
|
|
81
84
|
"provisions a new tenant, its first user and membership before any tenant context exists";
|
|
@@ -123,7 +126,7 @@ async function claimBoundHandover(
|
|
|
123
126
|
return undefined;
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
export function createSignupConfirmHandler() {
|
|
129
|
+
export function createSignupConfirmHandler(opts: SignupConfirmOptions = {}) {
|
|
127
130
|
return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
|
|
128
131
|
name: "signup-confirm",
|
|
129
132
|
schema: SignupConfirmSchema,
|
|
@@ -221,11 +224,25 @@ export function createSignupConfirmHandler() {
|
|
|
221
224
|
? await claimBoundHandover(ctx, session, handoverBinding)
|
|
222
225
|
: undefined;
|
|
223
226
|
|
|
227
|
+
// Before the token keys go: a throwing MFA check must leave the link retryable.
|
|
228
|
+
const mfaGate = await gateEnforceMfa(
|
|
229
|
+
ctx,
|
|
230
|
+
{ mfaStatusChecker: opts.mfaStatusChecker },
|
|
231
|
+
provisioned.userId,
|
|
232
|
+
provisioned.tenantId,
|
|
233
|
+
session.roles,
|
|
234
|
+
);
|
|
235
|
+
|
|
224
236
|
// Drop both token lookup keys; the burn key stays for its remaining
|
|
225
237
|
// TTL as replay protection.
|
|
226
238
|
await deleteSignupToken(ctx.redis, { email, token: event.payload.token });
|
|
227
239
|
if (handoverBinding) await deleteSignupHandover(ctx.redis, event.payload.token);
|
|
228
240
|
|
|
241
|
+
if (mfaGate !== undefined) {
|
|
242
|
+
committed = true;
|
|
243
|
+
return { isSuccess: true, data: mfaGate };
|
|
244
|
+
}
|
|
245
|
+
|
|
229
246
|
committed = true;
|
|
230
247
|
return {
|
|
231
248
|
isSuccess: true,
|
package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSessionRoles,
|
|
3
|
+
createSystemUser,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
SYSTEM_ROLE,
|
|
6
|
+
type TenantId,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
|
+
import * as z from "zod";
|
|
10
|
+
import { UserQueries } from "../../user/index.js";
|
|
11
|
+
import { parseAuthUserRow } from "../auth-user-row.js";
|
|
12
|
+
import { noMembership } from "../errors.js";
|
|
13
|
+
import { gateEnforceMfa, type LoginHandlerOptions } from "./login.write.js";
|
|
14
|
+
|
|
15
|
+
const SwitchTenantMfaGateSchema = z.object({
|
|
16
|
+
userId: z.string().min(1),
|
|
17
|
+
tenantId: z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1)
|
|
20
|
+
.transform((id) => id as TenantId),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type SwitchTenantMfaGateData =
|
|
24
|
+
| { readonly kind: "mfa-gate-clear" }
|
|
25
|
+
| NonNullable<Awaited<ReturnType<typeof gateEnforceMfa>>>;
|
|
26
|
+
|
|
27
|
+
// Dispatched by POST /auth/switch-tenant with the system identity. The handler
|
|
28
|
+
// resolves membership and roles itself, so the gate never trusts caller-supplied
|
|
29
|
+
// roles; system-only access keeps it out of reach of /api/write callers.
|
|
30
|
+
export function createSwitchTenantMfaGateHandler(
|
|
31
|
+
opts: Pick<LoginHandlerOptions, "mfaStatusChecker">,
|
|
32
|
+
) {
|
|
33
|
+
return defineWriteHandler<
|
|
34
|
+
"switch-tenant-mfa-gate",
|
|
35
|
+
typeof SwitchTenantMfaGateSchema,
|
|
36
|
+
SwitchTenantMfaGateData
|
|
37
|
+
>({
|
|
38
|
+
name: "switch-tenant-mfa-gate",
|
|
39
|
+
schema: SwitchTenantMfaGateSchema,
|
|
40
|
+
access: { roles: [SYSTEM_ROLE] },
|
|
41
|
+
escapeHatch: {
|
|
42
|
+
reason:
|
|
43
|
+
"reads the MFA enrollment of the switch target tenant, not the caller's current tenant, via the mfaStatusChecker callback (it uses ctx.db.unsafeRaw())",
|
|
44
|
+
},
|
|
45
|
+
agent: { expose: false },
|
|
46
|
+
description:
|
|
47
|
+
"Runs the MFA gate of the login for a tenant switch and answers with an MFA challenge, a setup requirement, or clearance.",
|
|
48
|
+
handler: async (event, ctx) => {
|
|
49
|
+
const { userId, tenantId } = event.payload;
|
|
50
|
+
const active = await ctx.resolveActiveMembership(userId, tenantId);
|
|
51
|
+
if (active.kind === "rejected") return noMembership();
|
|
52
|
+
const userRow = parseAuthUserRow(
|
|
53
|
+
await ctx.queryAs(createSystemUser(tenantId), UserQueries.findForAuth, { id: userId }),
|
|
54
|
+
);
|
|
55
|
+
const roles = buildSessionRoles(parseRoles(userRow?.roles ?? null), active.membership.roles);
|
|
56
|
+
const mfaGate = await gateEnforceMfa(ctx, opts, userId, tenantId, roles);
|
|
57
|
+
return { isSuccess: true, data: mfaGate ?? { kind: "mfa-gate-clear" } };
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
16
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
17
|
+
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
17
18
|
import type { Temporal } from "temporal-polyfill";
|
|
18
19
|
import * as z from "zod";
|
|
19
20
|
import { UserQueries } from "../../user/index.js";
|
|
@@ -89,6 +90,12 @@ export type TokenRequestOptions = {
|
|
|
89
90
|
readonly locale?: AuthMailLocale;
|
|
90
91
|
};
|
|
91
92
|
|
|
93
|
+
// The requester is anonymous, so ctx.tz.user is only the tenant default; the recipient's
|
|
94
|
+
// own profile zone is the one the expiry should be read in.
|
|
95
|
+
function recipientTimeZone(user: AuthUserRow, fallbackTimeZone: string): string {
|
|
96
|
+
return user.timezone && isValidIanaTimeZone(user.timezone) ? user.timezone : fallbackTimeZone;
|
|
97
|
+
}
|
|
98
|
+
|
|
92
99
|
export function createTokenRequestHandler<TName extends string, TSuccessKind extends string>(
|
|
93
100
|
spec: TokenRequestSpec<TName, TSuccessKind>,
|
|
94
101
|
opts: TokenRequestOptions,
|
|
@@ -156,7 +163,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
156
163
|
token,
|
|
157
164
|
expiresAt: expiresAt.toString(),
|
|
158
165
|
issuedAt,
|
|
159
|
-
timeZone: ctx.tz.user,
|
|
166
|
+
timeZone: recipientTimeZone(user, ctx.tz.user),
|
|
160
167
|
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
161
168
|
locale,
|
|
162
169
|
},
|
|
@@ -116,6 +116,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
116
116
|
"auth.signupComplete.submitting": "…",
|
|
117
117
|
"auth.signupComplete.missingToken":
|
|
118
118
|
"Activation link is missing a token. Please request a new one.",
|
|
119
|
+
"auth.signupComplete.activatedMfaPending":
|
|
120
|
+
"Your account is active. Sign in to set up two-factor authentication.",
|
|
119
121
|
"auth.signupComplete.activatedTitle": "Account activated",
|
|
120
122
|
"auth.signupComplete.activated": "Your account is active and you're signed in.",
|
|
121
123
|
"auth.signupComplete.continue": "Continue",
|
|
@@ -345,10 +345,10 @@ export async function requestSignup(
|
|
|
345
345
|
return { ok: false, error: await parseTokenFailure(res) };
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
-
// POST /api/auth/signup-confirm.
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
348
|
+
// POST /api/auth/signup-confirm. On success the server sets the auth cookies
|
|
349
|
+
// (auto-login) and returns the tenantKey for the post-signup redirect, unless
|
|
350
|
+
// the MFA gate applies (kind "mfa-pending", no session). 422 invalid_signup_token
|
|
351
|
+
// for an expired or unknown token.
|
|
352
352
|
export type SignupConfirmSuccess = {
|
|
353
353
|
readonly user: { readonly id: string; readonly tenantId: string; readonly roles: string[] };
|
|
354
354
|
readonly tenantKey: string;
|
|
@@ -360,10 +360,16 @@ export type SignupConfirmSuccess = {
|
|
|
360
360
|
readonly landingPath?: string;
|
|
361
361
|
};
|
|
362
362
|
|
|
363
|
+
// mfa-pending: account exists but the server issued no session because the
|
|
364
|
+
// new roles require a second factor — the user must sign in to enroll.
|
|
365
|
+
export type SignupConfirmResult =
|
|
366
|
+
| ({ readonly kind: "signed-in" } & SignupConfirmSuccess)
|
|
367
|
+
| { readonly kind: "mfa-pending" };
|
|
368
|
+
|
|
363
369
|
export async function confirmSignup(
|
|
364
370
|
token: string,
|
|
365
371
|
password: string,
|
|
366
|
-
): Promise<{ ok: true; data:
|
|
372
|
+
): Promise<{ ok: true; data: SignupConfirmResult } | { ok: false; error: AuthTokenFailure }> {
|
|
367
373
|
const res = await fetch("/api/auth/signup-confirm", {
|
|
368
374
|
method: "POST",
|
|
369
375
|
credentials: "same-origin",
|
|
@@ -371,8 +377,15 @@ export async function confirmSignup(
|
|
|
371
377
|
body: JSON.stringify({ token, password }),
|
|
372
378
|
});
|
|
373
379
|
if (res.ok) {
|
|
374
|
-
|
|
375
|
-
|
|
380
|
+
// @cast-boundary engine-payload
|
|
381
|
+
const body = (await res.json()) as SignupConfirmSuccess & {
|
|
382
|
+
readonly mfaRequired?: boolean;
|
|
383
|
+
readonly mfaSetupRequired?: boolean;
|
|
384
|
+
};
|
|
385
|
+
if (body.mfaRequired === true || body.mfaSetupRequired === true) {
|
|
386
|
+
return { ok: true, data: { kind: "mfa-pending" } };
|
|
387
|
+
}
|
|
388
|
+
return { ok: true, data: { kind: "signed-in", ...body } };
|
|
376
389
|
}
|
|
377
390
|
return { ok: false, error: await parseTokenFailure(res) };
|
|
378
391
|
}
|