@cosmicdrift/kumiko-samples 0.341.0 → 0.343.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.
Files changed (85) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/constants.ts +9 -0
  5. package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +1 -0
  6. package/packages/bundled-features/src/audit/feature.ts +47 -6
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +144 -0
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +18 -2
  9. package/packages/bundled-features/src/auth-email-password/changes.json +19 -0
  10. package/packages/bundled-features/src/auth-email-password/email-templates.ts +76 -16
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -1
  13. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -0
  14. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +4 -0
  15. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
  16. package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
  17. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
  18. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
  19. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
  20. package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
  21. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
  22. package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
  23. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
  24. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
  25. package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
  26. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
  27. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
  28. package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
  29. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  30. package/packages/bundled-features/src/channel-email/email-channel.ts +8 -1
  31. package/packages/bundled-features/src/channel-email/feature.ts +1 -0
  32. package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
  33. package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
  34. package/packages/bundled-features/src/channel-email/types.ts +4 -0
  35. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -0
  36. package/packages/bundled-features/src/channel-push/feature.ts +1 -0
  37. package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
  38. package/packages/bundled-features/src/channel-teams/changes.json +8 -0
  39. package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
  40. package/packages/bundled-features/src/channel-teams/teams-channel.ts +16 -0
  41. package/packages/bundled-features/src/channel-telegram/feature.ts +1 -0
  42. package/packages/bundled-features/src/delivery/changes.json +32 -0
  43. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +7 -2
  44. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +60 -14
  45. package/packages/bundled-features/src/delivery/constants.ts +4 -0
  46. package/packages/bundled-features/src/delivery/delivery-service.ts +70 -5
  47. package/packages/bundled-features/src/delivery/events.ts +15 -1
  48. package/packages/bundled-features/src/delivery/feature.ts +32 -4
  49. package/packages/bundled-features/src/delivery/handlers/log.query.ts +65 -20
  50. package/packages/bundled-features/src/delivery/i18n.ts +55 -0
  51. package/packages/bundled-features/src/delivery/index.ts +2 -0
  52. package/packages/bundled-features/src/delivery/jobs.ts +43 -20
  53. package/packages/bundled-features/src/delivery/notify-factory.ts +19 -0
  54. package/packages/bundled-features/src/delivery/public-names.ts +4 -0
  55. package/packages/bundled-features/src/delivery/tables.ts +3 -0
  56. package/packages/bundled-features/src/delivery/testing.ts +5 -9
  57. package/packages/bundled-features/src/delivery/types.ts +6 -2
  58. package/packages/bundled-features/src/delivery/web/client-plugin.tsx +16 -1
  59. package/packages/bundled-features/src/delivery/web/delivery-channel-cell.tsx +13 -0
  60. package/packages/bundled-features/src/delivery/web/delivery-error-cell.tsx +17 -0
  61. package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +10 -3
  62. package/packages/bundled-features/src/delivery/web/delivery-time-cell.tsx +8 -0
  63. package/packages/bundled-features/src/delivery/web/delivery-type-cell.tsx +20 -0
  64. package/packages/bundled-features/src/jobs/changes.json +6 -0
  65. package/packages/bundled-features/src/secrets/changes.json +6 -0
  66. package/packages/bundled-features/src/secrets/constants.ts +10 -0
  67. package/packages/bundled-features/src/secrets/feature.ts +4 -0
  68. package/packages/bundled-features/src/secrets/i18n.ts +5 -3
  69. package/packages/bundled-features/src/secrets/index.ts +8 -1
  70. package/packages/bundled-features/src/secrets/write-gate.ts +17 -5
  71. package/packages/bundled-features/src/shared/index.ts +6 -0
  72. package/packages/bundled-features/src/shared/redact.ts +27 -0
  73. package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -6
  74. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +0 -6
  75. package/packages/bundled-features/src/tenant/web/member-roles-cell.tsx +1 -1
  76. package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
  77. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +12 -2
  78. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -8
  79. package/packages/bundled-features/src/user/index.ts +1 -0
  80. package/packages/bundled-features/src/user/resolve-display-names.ts +35 -0
  81. package/packages/bundled-features/src/waitlist/changes.json +6 -0
  82. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +1 -1
  83. package/packages/bundled-features/src/waitlist/options.ts +3 -2
  84. package/samples/apps/use-all-bundled/feature-manifest.json +27 -2
  85. /package/packages/bundled-features/src/{tenant → shared}/web/translate-or-raw.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.341.0",
3
+ "version": "0.343.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.341.0",
3
+ "version": "0.343.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,10 @@
1
1
  [
2
+ {
3
+ "version": "0.343.0",
4
+ "type": "improvement",
5
+ "title": "escape-hatch retention follows the tenant compliance profile",
6
+ "detail": "The audit job `escape-hatch-retention` now deletes `escapeHatchUse` events after the `auditLog.retention` of the tenant's compliance profile. A tenant override can only lengthen that period, never go below the base retention of the selected profile. Cross-tenant audits (with `targetTenantId`, e.g. `identity-switch`) additionally follow the profile of the target tenant. The config key `audit:config:escape-hatch-retention-days` is only the fallback when compliance-profiles is not mounted.\n`pruneEvents` gains the optional `aggregateIds` option to restrict the prune to specific aggregates. `@cosmicdrift/kumiko-framework/compliance` exports `subtractRetentionSpec` and the `RetentionSpec` type for calendar-aware cutoffs (months and years)."
7
+ },
2
8
  {
3
9
  "version": "0.313.0",
4
10
  "type": "fix",
@@ -24,3 +24,12 @@ export const AUDIT_LOG_SCREEN_ID = "audit-log" as const;
24
24
 
25
25
  /** Single-event detail screen, breadcrumb-linked to the audit-log list. */
26
26
  export const AUDIT_LOG_DETAIL_SCREEN_ID = "audit-log-detail" as const;
27
+
28
+ /**
29
+ * Config key (system scope) for how long `escapeHatchUse` audit events are kept. Only a fallback
30
+ * for apps without compliance-profiles; with it mounted, the tenant profile's `auditLog.retention`
31
+ * applies (a tenant override can only lengthen it).
32
+ */
33
+ export const ESCAPE_HATCH_RETENTION_DAYS_KEY = "audit:config:escape-hatch-retention-days" as const;
34
+
35
+ export const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
@@ -15,6 +15,7 @@ const escapeHatchKinds = [
15
15
  "global-write",
16
16
  "identity-switch",
17
17
  "unsafe-all-tenants",
18
+ "cross-tenant-read",
18
19
  ] as const satisfies readonly EscapeHatchKind[];
19
20
 
20
21
  export const escapeHatchUsedSchema = z.object({
@@ -1,14 +1,22 @@
1
1
  import {
2
2
  access,
3
+ createSystemConfig,
3
4
  defineFeature,
4
5
  type FeatureDefinition,
5
6
  i18nKey,
7
+ SYSTEM_USER_ID,
6
8
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "./constants.js";
9
+ import {
10
+ AUDIT_LOG_DETAIL_SCREEN_ID,
11
+ AUDIT_LOG_SCREEN_ID,
12
+ AuditQueries,
13
+ DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
14
+ } from "./constants.js";
8
15
  import { escapeHatchUsedSchema } from "./escape-hatch-audit-sink.js";
9
16
  import { detailsQuery } from "./handlers/details.query.js";
10
17
  import { listQuery } from "./handlers/list.query.js";
11
18
  import { AUDIT_I18N } from "./i18n.js";
19
+ import { runEscapeHatchRetention } from "./run-escape-hatch-retention.js";
12
20
 
13
21
  // Audit feature — exposes a filtered read over the framework's event log.
14
22
  //
@@ -18,10 +26,11 @@ import { AUDIT_I18N } from "./i18n.js";
18
26
  // the existing `events` table. See handlers/list.query.ts for the filter
19
27
  // surface.
20
28
  //
21
- // Retention lives elsewhere. Events are kept indefinitely as the source of
22
- // truth for state; archive or compress policies are a separate concern
23
- // (tracked with the snapshot/archive infrastructure that already exists in
24
- // the framework).
29
+ // Retention: only `escapeHatchUse` events are pruned (daily job, config key
30
+ // `escapeHatchRetentionDays`). All other events stay as the source of truth
31
+ // for state; archive or compress policies are a separate concern (tracked
32
+ // with the snapshot/archive infrastructure that already exists in the
33
+ // framework).
25
34
  export function createAuditFeature(): FeatureDefinition {
26
35
  return defineFeature(
27
36
  "audit",
@@ -35,11 +44,43 @@ export function createAuditFeature(): FeatureDefinition {
35
44
  recommended: false,
36
45
  });
37
46
  r.translations({ keys: AUDIT_I18N });
38
- r.requires("tenant", "user");
47
+ r.requires("tenant", "user", "config");
39
48
 
40
49
  // Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
41
50
  r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
42
51
 
52
+ r.config(
53
+ "escapeHatchRetentionDays",
54
+ createSystemConfig("number", {
55
+ default: DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
56
+ bounds: { min: 1 },
57
+ }),
58
+ );
59
+
60
+ r.job({
61
+ name: "escape-hatch-retention",
62
+ trigger: { cron: "30 3 * * *" },
63
+ concurrency: "skip",
64
+ // pruneEvents needs a transaction-capable connection, which only unsafeRaw() hands a job.
65
+ escapeHatch: {
66
+ reason: "prunes expired escapeHatchUse audit events through pruneEvents",
67
+ grants: ["unsafeRaw"],
68
+ },
69
+ handler: async (_payload, ctx) => {
70
+ if (!ctx.registry) {
71
+ throw new Error(
72
+ "escape-hatch-retention: ctx.registry required (JobContext incomplete)",
73
+ );
74
+ }
75
+ await runEscapeHatchRetention({
76
+ db: ctx.db.unsafeRaw(),
77
+ registry: ctx.registry,
78
+ configResolver: ctx.configResolver,
79
+ userId: ctx._userId ?? SYSTEM_USER_ID,
80
+ });
81
+ },
82
+ });
83
+
43
84
  const queries = {
44
85
  list: r.queryHandler(listQuery),
45
86
  details: r.queryHandler(detailsQuery),
@@ -0,0 +1,144 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ COMPLIANCE_PROFILES,
4
+ subtractRetentionSpec,
5
+ } from "@cosmicdrift/kumiko-framework/compliance";
6
+ import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
7
+ import {
8
+ type ConfigResolver,
9
+ parseTenantId,
10
+ type Registry,
11
+ SYSTEM_TENANT_ID,
12
+ type TenantId,
13
+ } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
+ import { pruneEvents } from "@cosmicdrift/kumiko-framework/pipeline";
16
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
17
+ import { resolveProfileForTenant } from "../compliance-profiles/index.js";
18
+ import { createConfigAccessor } from "../config/index.js";
19
+ import {
20
+ DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
21
+ ESCAPE_HATCH_RETENTION_DAYS_KEY,
22
+ } from "./constants.js";
23
+ import {
24
+ ESCAPE_HATCH_USE_AGGREGATE_TYPE,
25
+ escapeHatchUsedSchema,
26
+ } from "./escape-hatch-audit-sink.js";
27
+
28
+ // pruneEvents opens its own transaction, so a runner that cannot begin one (a tx handle) is rejected instead of nested.
29
+ export function isDbConnection(runner: DbRunner): runner is DbConnection {
30
+ return "begin" in runner && typeof runner.begin === "function";
31
+ }
32
+
33
+ async function resolveRetentionDays(args: {
34
+ readonly registry: Registry;
35
+ readonly configResolver: ConfigResolver | undefined;
36
+ readonly db: DbConnection;
37
+ readonly userId: string;
38
+ }): Promise<number> {
39
+ if (!args.configResolver) return DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
40
+ const config = createConfigAccessor(
41
+ args.registry,
42
+ args.configResolver,
43
+ SYSTEM_TENANT_ID,
44
+ args.userId,
45
+ args.db,
46
+ );
47
+ const raw = await config(ESCAPE_HATCH_RETENTION_DAYS_KEY);
48
+ return typeof raw === "number" && raw >= 1 ? raw : DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
49
+ }
50
+
51
+ // Soft dependency: audit must stay mountable without compliance-profiles, so we probe its entity.
52
+ const COMPLIANCE_PROFILE_ENTITY = "tenant-compliance-profile";
53
+ const PRUNE_CHUNK_SIZE = 1000;
54
+
55
+ type AuditEventRow = {
56
+ readonly aggregateId: string;
57
+ readonly tenantId: TenantId;
58
+ readonly createdAt: Temporal.Instant;
59
+ readonly payload: unknown;
60
+ };
61
+
62
+ // Overrides may only lengthen retention: the earlier cutoff (same `now`) is the longer period.
63
+ function earlierInstant(a: Temporal.Instant, b: Temporal.Instant): Temporal.Instant {
64
+ return Temporal.Instant.compare(a, b) <= 0 ? a : b;
65
+ }
66
+
67
+ async function resolveTenantCutoff(
68
+ db: DbConnection,
69
+ tenantId: TenantId,
70
+ now: Temporal.Instant,
71
+ ): Promise<Temporal.Instant> {
72
+ const { profile } = await resolveProfileForTenant({ db, tenantId });
73
+ const effective = subtractRetentionSpec(now, profile.auditLog.retention);
74
+ const base = subtractRetentionSpec(now, COMPLIANCE_PROFILES[profile.key].auditLog.retention);
75
+ return earlierInstant(effective, base);
76
+ }
77
+
78
+ // A cross-tenant audit (e.g. identity-switch) lives under the actor's tenant but must also
79
+ // satisfy the target tenant's retention. A malformed payload falls back to the storing tenant.
80
+ function targetTenantOf(payload: unknown): TenantId | null {
81
+ const parsed = escapeHatchUsedSchema.safeParse(payload);
82
+ return parsed.success ? parseTenantId(parsed.data.targetTenantId) : null;
83
+ }
84
+
85
+ async function pruneByTenantProfiles(db: DbConnection): Promise<number> {
86
+ const rows = await selectMany<AuditEventRow>(db, eventsTable, {
87
+ aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
88
+ });
89
+ const now = getTemporal().Now.instant();
90
+ const cutoffByTenant = new Map<TenantId, Temporal.Instant>();
91
+ const cutoffOf = async (tenantId: TenantId): Promise<Temporal.Instant> => {
92
+ const cached = cutoffByTenant.get(tenantId);
93
+ if (cached) return cached;
94
+ const cutoff = await resolveTenantCutoff(db, tenantId, now);
95
+ cutoffByTenant.set(tenantId, cutoff);
96
+ return cutoff;
97
+ };
98
+
99
+ const expiredAggregateIds: string[] = [];
100
+ for (const row of rows) {
101
+ const targetTenantId = targetTenantOf(row.payload);
102
+ const storeCutoff = await cutoffOf(row.tenantId);
103
+ const cutoff = targetTenantId
104
+ ? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
105
+ : storeCutoff;
106
+ if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
107
+ expiredAggregateIds.push(row.aggregateId);
108
+ }
109
+ }
110
+
111
+ let deletedCount = 0;
112
+ for (let i = 0; i < expiredAggregateIds.length; i += PRUNE_CHUNK_SIZE) {
113
+ const result = await pruneEvents(db, {
114
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
115
+ aggregateIds: expiredAggregateIds.slice(i, i + PRUNE_CHUNK_SIZE),
116
+ olderThan: now,
117
+ });
118
+ deletedCount += result.deletedCount;
119
+ }
120
+ return deletedCount;
121
+ }
122
+
123
+ export async function runEscapeHatchRetention(args: {
124
+ readonly db: DbRunner;
125
+ readonly registry: Registry;
126
+ readonly configResolver: ConfigResolver | undefined;
127
+ readonly userId: string;
128
+ }): Promise<{ readonly deletedCount: number }> {
129
+ const { db } = args;
130
+ if (!isDbConnection(db)) {
131
+ throw new Error("audit escape-hatch retention: a transaction-free DbConnection is required");
132
+ }
133
+
134
+ if (!args.registry.getEntity(COMPLIANCE_PROFILE_ENTITY)) {
135
+ const olderThanDays = await resolveRetentionDays({ ...args, db });
136
+ const { deletedCount } = await pruneEvents(db, {
137
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
138
+ olderThanDays,
139
+ });
140
+ return { deletedCount };
141
+ }
142
+
143
+ return { deletedCount: await pruneByTenantProfiles(db) };
144
+ }
@@ -5,13 +5,17 @@
5
5
  // invitation row holds membership role + global roles until the invitee
6
6
  // accepts through the regular invite-accept routes.
7
7
 
8
- import { makeDispatchSystemWrite } from "@cosmicdrift/kumiko-framework/api";
8
+ import {
9
+ makeDispatchSystemWrite,
10
+ type SystemDispatchArgs,
11
+ } from "@cosmicdrift/kumiko-framework/api";
9
12
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
10
13
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
11
14
  import {
12
15
  ConfigScopes,
13
16
  createSystemUser,
14
17
  type TenantId,
18
+ type WriteResult,
15
19
  } from "@cosmicdrift/kumiko-framework/engine";
16
20
  import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
17
21
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
@@ -48,6 +52,12 @@ export type BootstrapSeedDeps = {
48
52
  readonly tenantId: TenantId;
49
53
  readonly db: DbConnection;
50
54
  readonly dispatchSystemWrite: ReturnType<typeof makeDispatchSystemWrite>;
55
+ /** Writes as the system user with the given tenant roles in the seeded
56
+ * tenant; use it when the app gates tenant data on its own roles rather
57
+ * than SystemAdmin. */
58
+ readonly dispatchWriteAs: (
59
+ args: Pick<SystemDispatchArgs, "handlerQn" | "payload"> & { readonly roles: readonly string[] },
60
+ ) => Promise<WriteResult>;
51
61
  };
52
62
 
53
63
  export type BootstrapConfigValue = string | number | boolean;
@@ -276,7 +286,13 @@ export async function bootstrapTenants(
276
286
  const outcome = await ensureTenant(deps, tenant);
277
287
  const shouldSeed = outcome === "created" && plan.seed !== undefined;
278
288
  if (shouldSeed) {
279
- await plan.seed?.({ tenantId: tenant.id, db: deps.db, dispatchSystemWrite });
289
+ await plan.seed?.({
290
+ tenantId: tenant.id,
291
+ db: deps.db,
292
+ dispatchSystemWrite,
293
+ dispatchWriteAs: ({ handlerQn, payload, roles }) =>
294
+ deps.dispatcher.write(handlerQn, payload, createSystemUser(tenant.id, roles)),
295
+ });
280
296
  }
281
297
  const configApplied = await applyTenantConfig(deps, tenant);
282
298
  tenants.push({ id: tenant.id, outcome, seeded: shouldSeed, configApplied });
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "0.343.0",
4
+ "type": "improvement",
5
+ "title": "Mail expiry shows duration and local time with time zone",
6
+ "detail": "Auth mails state the link validity as a duration and in the user's time zone\nThe five token mail renderers replace the UTC timestamp with `{duration}` (for example \"1 day\") and `{when}` (medium date and short time with zone abbreviation, in the recipient's locale). `RenderTokenContentArgs` gains `issuedAt` (default now) and `timeZone` (default UTC; an invalid zone falls back to UTC); the handlers pass the token issue time and `ctx.tz.user`. German strings use \"Konto\" instead of \"Account\".",
7
+ "migration": "Custom `auth.mail.*.expiry` translations should use the new `{duration}` and `{when}` placeholders; `{when}` now includes the time and zone abbreviation instead of a UTC timestamp."
8
+ },
9
+ {
10
+ "version": "0.342.0",
11
+ "type": "improvement",
12
+ "title": "AuthCard styling hooks (className, headerClassName, titleClassName, bodyClassName)",
13
+ "detail": "AuthCard takes className, headerClassName, titleClassName and bodyClassName\n`AuthCard` renders the body wrapper (`p-6 pt-0 flex flex-col gap-4`) itself and accepts `className` (Card), `headerClassName`, `titleClassName` and `bodyClassName`, merged with tailwind-merge over the defaults. The auth-email-password and auth-mfa screens no longer carry their own body wrapper; the rendered DOM stays the same. The session bootstrap error screen keeps its former bottom padding through `bodyClassName`."
14
+ },
15
+ {
16
+ "version": "0.342.0",
17
+ "type": "improvement",
18
+ "title": "Bootstrap seed can write with app tenant roles",
19
+ "detail": "Bootstrap seed gets `dispatchWriteAs` to write with app tenant roles instead of SystemAdmin."
20
+ },
2
21
  {
3
22
  "version": "0.341.0",
4
23
  "type": "breaking",
@@ -20,21 +20,21 @@ export const AUTH_MAIL_EN: Readonly<Record<string, string>> = {
20
20
  "auth.mail.reset.intro":
21
21
  "you requested a password reset for {app}. Click the link below to set a new password:",
22
22
  "auth.mail.reset.button": "Reset password",
23
- "auth.mail.reset.expiry": "The link expires on {when}.",
23
+ "auth.mail.reset.expiry": "The link is valid for {duration} (until {when}).",
24
24
  "auth.mail.reset.ignore":
25
25
  "If you didn't request a reset, you can safely ignore this email — your password won't change.",
26
26
  "auth.mail.verify.subject": "{app} — Verify your email",
27
27
  "auth.mail.verify.greeting": "Welcome,",
28
28
  "auth.mail.verify.intro": "please verify your email address for {app} to activate your account:",
29
29
  "auth.mail.verify.button": "Verify email",
30
- "auth.mail.verify.expiry": "The link expires on {when}.",
30
+ "auth.mail.verify.expiry": "The link is valid for {duration} (until {when}).",
31
31
  "auth.mail.verify.ignore": "If you didn't create this account, you can ignore this email.",
32
32
  "auth.mail.activation.subject": "{app} — Activate your account",
33
33
  "auth.mail.activation.greeting": "Welcome,",
34
34
  "auth.mail.activation.intro":
35
35
  "click the link below to activate your {app} account. The next step is choosing your password:",
36
36
  "auth.mail.activation.button": "Activate account",
37
- "auth.mail.activation.expiry": "The link expires on {when}.",
37
+ "auth.mail.activation.expiry": "The link is valid for {duration} (until {when}).",
38
38
  "auth.mail.activation.ignore":
39
39
  "If you didn't sign up, you can ignore this email — no account is created until you open the link.",
40
40
  "auth.mail.invite.subject": "{app} — Workspace invitation",
@@ -42,14 +42,14 @@ export const AUTH_MAIL_EN: Readonly<Record<string, string>> = {
42
42
  "auth.mail.invite.intro":
43
43
  "you've been invited to a {app} workspace as {role}. Click the link below to accept:",
44
44
  "auth.mail.invite.button": "Accept invitation",
45
- "auth.mail.invite.expiry": "The link expires on {when}.",
45
+ "auth.mail.invite.expiry": "The link is valid for {duration} (until {when}).",
46
46
  "auth.mail.invite.ignore": "If you weren't expecting this invitation, you can ignore this email.",
47
47
  "auth.mail.unlock.subject": "{app} — Unlock your account",
48
48
  "auth.mail.unlock.greeting": "Hi,",
49
49
  "auth.mail.unlock.intro":
50
50
  "your {app} account was temporarily locked after several failed sign-in attempts. Click the link below to unlock it immediately:",
51
51
  "auth.mail.unlock.button": "Unlock account",
52
- "auth.mail.unlock.expiry": "The link expires on {when}.",
52
+ "auth.mail.unlock.expiry": "The link is valid for {duration} (until {when}).",
53
53
  "auth.mail.unlock.ignore":
54
54
  "If you didn't trigger this lock, you can ignore this email — the lock expires on its own.",
55
55
  };
@@ -62,6 +62,10 @@ export type RenderTokenContentArgs = {
62
62
  readonly locale?: AuthMailLocale;
63
63
  /** Optional app name for subject + intro. Default from auth.mail.appNameDefault. */
64
64
  readonly appName?: string;
65
+ /** ISO instant the token was issued; the validity duration is measured from it. Default: now. */
66
+ readonly issuedAt?: string;
67
+ /** IANA zone the expiry time is shown in. Default "UTC"; an invalid zone falls back to "UTC". */
68
+ readonly timeZone?: string;
65
69
  };
66
70
 
67
71
  export type RenderInviteEmailArgs = RenderTokenContentArgs & { readonly role: string };
@@ -119,7 +123,7 @@ export function renderResetPasswordEmail(args: RenderTokenContentArgs): AuthMail
119
123
  intro: t(locale, "auth.mail.reset.intro", { app }),
120
124
  buttonLabel: t(locale, "auth.mail.reset.button"),
121
125
  buttonUrl: args.url,
122
- expiry: t(locale, "auth.mail.reset.expiry", { when: formatExpiry(args.expiresAt) }),
126
+ expiry: t(locale, "auth.mail.reset.expiry", expiryParams(args, locale)),
123
127
  ignore: t(locale, "auth.mail.reset.ignore"),
124
128
  });
125
129
  }
@@ -134,7 +138,7 @@ export function renderUnlockAccountEmail(args: RenderTokenContentArgs): AuthMail
134
138
  intro: t(locale, "auth.mail.unlock.intro", { app }),
135
139
  buttonLabel: t(locale, "auth.mail.unlock.button"),
136
140
  buttonUrl: args.url,
137
- expiry: t(locale, "auth.mail.unlock.expiry", { when: formatExpiry(args.expiresAt) }),
141
+ expiry: t(locale, "auth.mail.unlock.expiry", expiryParams(args, locale)),
138
142
  ignore: t(locale, "auth.mail.unlock.ignore"),
139
143
  });
140
144
  }
@@ -149,7 +153,7 @@ export function renderVerifyEmail(args: RenderTokenContentArgs): AuthMailContent
149
153
  intro: t(locale, "auth.mail.verify.intro", { app }),
150
154
  buttonLabel: t(locale, "auth.mail.verify.button"),
151
155
  buttonUrl: args.url,
152
- expiry: t(locale, "auth.mail.verify.expiry", { when: formatExpiry(args.expiresAt) }),
156
+ expiry: t(locale, "auth.mail.verify.expiry", expiryParams(args, locale)),
153
157
  ignore: t(locale, "auth.mail.verify.ignore"),
154
158
  });
155
159
  }
@@ -164,7 +168,7 @@ export function renderActivationEmail(args: RenderTokenContentArgs): AuthMailCon
164
168
  intro: t(locale, "auth.mail.activation.intro", { app }),
165
169
  buttonLabel: t(locale, "auth.mail.activation.button"),
166
170
  buttonUrl: args.url,
167
- expiry: t(locale, "auth.mail.activation.expiry", { when: formatExpiry(args.expiresAt) }),
171
+ expiry: t(locale, "auth.mail.activation.expiry", expiryParams(args, locale)),
168
172
  ignore: t(locale, "auth.mail.activation.ignore"),
169
173
  });
170
174
  }
@@ -179,20 +183,76 @@ export function renderInviteEmail(args: RenderInviteEmailArgs): AuthMailContent
179
183
  intro: t(locale, "auth.mail.invite.intro", { app, role: args.role }),
180
184
  buttonLabel: t(locale, "auth.mail.invite.button"),
181
185
  buttonUrl: args.url,
182
- expiry: t(locale, "auth.mail.invite.expiry", { when: formatExpiry(args.expiresAt) }),
186
+ expiry: t(locale, "auth.mail.invite.expiry", expiryParams(args, locale)),
183
187
  ignore: t(locale, "auth.mail.invite.ignore"),
184
188
  });
185
189
  }
186
190
 
187
- function formatExpiry(iso: string): string {
191
+ const MS_PER_MINUTE = 60_000;
192
+ const MS_PER_HOUR = 60 * MS_PER_MINUTE;
193
+ const MS_PER_DAY = 24 * MS_PER_HOUR;
194
+
195
+ type DurationUnit = "day" | "hour" | "minute";
196
+
197
+ // Rounds to whole minutes first so a few ms between issuing and signing still
198
+ // read as "1 day"; a larger unit is only used when it divides the span exactly.
199
+ function durationParts(ms: number): { readonly amount: number; readonly unit: DurationUnit } {
200
+ const minutes = Math.max(1, Math.round(ms / MS_PER_MINUTE));
201
+ const minutesPerHour = MS_PER_HOUR / MS_PER_MINUTE;
202
+ const minutesPerDay = MS_PER_DAY / MS_PER_MINUTE;
203
+ if (minutes % minutesPerDay === 0) return { amount: minutes / minutesPerDay, unit: "day" };
204
+ if (minutes % minutesPerHour === 0) return { amount: minutes / minutesPerHour, unit: "hour" };
205
+ return { amount: minutes, unit: "minute" };
206
+ }
207
+
208
+ function formatDuration(ms: number, locale: string): string {
209
+ const { amount, unit } = durationParts(ms);
210
+ const options: Intl.NumberFormatOptions = { style: "unit", unit, unitDisplay: "long" };
188
211
  try {
189
- const z = Temporal.Instant.from(iso).toZonedDateTimeISO("UTC");
190
- return `${z.year}-${pad2(z.month)}-${pad2(z.day)} ${pad2(z.hour)}:${pad2(z.minute)} UTC`;
212
+ return new Intl.NumberFormat(locale, options).format(amount);
191
213
  } catch {
192
- return iso;
214
+ return new Intl.NumberFormat("en", options).format(amount);
193
215
  }
194
216
  }
195
217
 
196
- function pad2(n: number): string {
197
- return String(n).padStart(2, "0");
218
+ // Intl rejects timeZoneName together with dateStyle/timeStyle, so the zone
219
+ // abbreviation is read from a second formatter and appended.
220
+ function formatWhen(expiresAt: Temporal.Instant, locale: string, timeZone: string): string {
221
+ const build = (tag: string, zone: string): string => {
222
+ const at = expiresAt.epochMilliseconds;
223
+ const dateTime = new Intl.DateTimeFormat(tag, {
224
+ dateStyle: "medium",
225
+ timeStyle: "short",
226
+ timeZone: zone,
227
+ }).format(at);
228
+ const zoneName = new Intl.DateTimeFormat(tag, { timeZone: zone, timeZoneName: "short" })
229
+ .formatToParts(at)
230
+ .find((part) => part.type === "timeZoneName")?.value;
231
+ return zoneName ? `${dateTime} ${zoneName}` : dateTime;
232
+ };
233
+ try {
234
+ return build(locale, timeZone);
235
+ } catch {
236
+ try {
237
+ return build(locale, "UTC");
238
+ } catch {
239
+ return build("en", "UTC");
240
+ }
241
+ }
242
+ }
243
+
244
+ function expiryParams(
245
+ args: RenderTokenContentArgs,
246
+ locale: string,
247
+ ): { readonly duration: string; readonly when: string } {
248
+ try {
249
+ const expiresAt = Temporal.Instant.from(args.expiresAt);
250
+ const issuedAt = args.issuedAt ? Temporal.Instant.from(args.issuedAt) : Temporal.Now.instant();
251
+ return {
252
+ duration: formatDuration(expiresAt.epochMilliseconds - issuedAt.epochMilliseconds, locale),
253
+ when: formatWhen(expiresAt, locale, args.timeZone ?? "UTC"),
254
+ };
255
+ } catch {
256
+ return { duration: args.expiresAt, when: args.expiresAt };
257
+ }
198
258
  }
@@ -160,7 +160,8 @@ async function issueInvitation(
160
160
 
161
161
  const email = request.email.toLowerCase();
162
162
  const tenantId = inviter.tenantId;
163
- const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
163
+ const issuedAt = Temporal.Now.instant();
164
+ const expiresAt = issuedAt.add({ seconds: ttlSeconds });
164
165
 
165
166
  // The unique index allows one row per (tenantId, email). Whatever its
166
167
  // status, a re-invite resets it to pending with a fresh token.
@@ -233,6 +234,8 @@ async function issueInvitation(
233
234
  appUrl: opts.appUrl,
234
235
  token,
235
236
  expiresAt: expiresAt.toString(),
237
+ issuedAt: issuedAt.toString(),
238
+ timeZone: ctx.tz.user,
236
239
  ...(opts.appName !== undefined && { appName: opts.appName }),
237
240
  locale,
238
241
  },
@@ -150,7 +150,8 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
150
150
  // session tokens.
151
151
  const token = generateToken();
152
152
 
153
- const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
153
+ const issuedAt = Temporal.Now.instant();
154
+ const expiresAt = issuedAt.add({ seconds: ttlSeconds });
154
155
  const expiresAtIso = expiresAt.toString();
155
156
 
156
157
  await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
@@ -211,6 +212,8 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
211
212
  appUrl: opts.appUrl,
212
213
  token,
213
214
  expiresAt: expiresAtIso,
215
+ issuedAt: issuedAt.toString(),
216
+ timeZone: ctx.tz.user,
214
217
  ...(opts.appName !== undefined && { appName: opts.appName }),
215
218
  locale,
216
219
  },
@@ -137,6 +137,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
137
137
  }
138
138
 
139
139
  const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
140
+ const issuedAt = expiresAt.subtract({ minutes: ttl }).toString();
140
141
 
141
142
  const locale = resolveHandlerMailLocale(ctx, opts.locale);
142
143
 
@@ -154,6 +155,8 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
154
155
  appUrl: opts.appUrl,
155
156
  token,
156
157
  expiresAt: expiresAt.toString(),
158
+ issuedAt,
159
+ timeZone: ctx.tz.user,
157
160
  ...(opts.appName !== undefined && { appName: opts.appName }),
158
161
  locale,
159
162
  },
@@ -34,6 +34,8 @@ export type MagicLinkMailParams = {
34
34
  readonly expiresAt: string;
35
35
  readonly appName?: string;
36
36
  readonly locale?: AuthMailLocale;
37
+ readonly issuedAt?: string;
38
+ readonly timeZone?: string;
37
39
  };
38
40
 
39
41
  function resolveAppUrl(appUrl: string | ((locale: string) => string), locale: string): string {
@@ -78,6 +80,8 @@ export async function dispatchMagicLinkMail(
78
80
  expiresAt: params.expiresAt,
79
81
  ...(params.locale !== undefined && { locale: params.locale }),
80
82
  ...(params.appName !== undefined && { appName: params.appName }),
83
+ ...(params.issuedAt !== undefined && { issuedAt: params.issuedAt }),
84
+ ...(params.timeZone !== undefined && { timeZone: params.timeZone }),
81
85
  });
82
86
  await notify(spec.notificationType, {
83
87
  route: { email: params.email },