@cosmicdrift/kumiko-bundled-features 0.147.1 → 0.147.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +12 -7
  2. package/src/auth-email-password/feature.ts +6 -1
  3. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  4. package/src/auth-email-password/handlers/login.write.ts +66 -9
  5. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +15 -5
  6. package/src/auth-email-password/web/__tests__/test-utils.tsx +12 -2
  7. package/src/auth-email-password/web/auth-client.ts +34 -12
  8. package/src/auth-email-password/web/auth-gate.tsx +32 -3
  9. package/src/auth-email-password/web/client-plugin.ts +11 -2
  10. package/src/auth-email-password/web/index.ts +4 -2
  11. package/src/auth-email-password/web/login-screen.tsx +31 -2
  12. package/src/auth-email-password/web/session.tsx +11 -6
  13. package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +202 -0
  14. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +150 -0
  15. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +167 -0
  16. package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +168 -0
  17. package/src/auth-mfa/__tests__/verify.integration.test.ts +186 -0
  18. package/src/auth-mfa/config.ts +26 -0
  19. package/src/auth-mfa/constants.ts +25 -0
  20. package/src/auth-mfa/db/queries.ts +34 -0
  21. package/src/auth-mfa/errors.ts +73 -0
  22. package/src/auth-mfa/feature.ts +146 -0
  23. package/src/auth-mfa/handlers/disable.write.ts +51 -0
  24. package/src/auth-mfa/handlers/enable-confirm.write.ts +68 -0
  25. package/src/auth-mfa/handlers/enable-start.write.ts +66 -0
  26. package/src/auth-mfa/handlers/reencrypt.job.ts +185 -0
  27. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +62 -0
  28. package/src/auth-mfa/handlers/verify.write.ts +141 -0
  29. package/src/auth-mfa/i18n.ts +12 -0
  30. package/src/auth-mfa/index.ts +15 -0
  31. package/src/auth-mfa/mfa-challenge-token.ts +90 -0
  32. package/src/auth-mfa/mfa-setup-token.ts +90 -0
  33. package/src/auth-mfa/mfa-status-checker.ts +75 -0
  34. package/src/auth-mfa/mfa-verify-attempts.ts +90 -0
  35. package/src/auth-mfa/recovery-codes.ts +44 -0
  36. package/src/auth-mfa/schema/user-mfa.ts +40 -0
  37. package/src/auth-mfa/totp.ts +8 -0
  38. package/src/auth-mfa/verify-factor.ts +30 -0
  39. package/src/auth-mfa/web/client-plugin.ts +37 -0
  40. package/src/auth-mfa/web/i18n.ts +69 -0
  41. package/src/auth-mfa/web/index.ts +15 -0
  42. package/src/auth-mfa/web/mfa-client.ts +60 -0
  43. package/src/auth-mfa/web/mfa-enable-screen.tsx +187 -0
  44. package/src/auth-mfa/web/mfa-verify-screen.tsx +106 -0
  45. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +132 -0
  46. package/src/auth-mfa-user-data/hooks.ts +53 -0
  47. package/src/auth-mfa-user-data/index.ts +21 -0
  48. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +4 -0
  49. package/src/legal-pages/feature.ts +19 -28
  50. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +111 -0
  51. package/src/managed-pages/feature.ts +44 -44
  52. package/src/seo/__tests__/seo.integration.test.ts +68 -0
  53. package/src/seo/feature.ts +28 -41
  54. package/src/sessions/feature.ts +2 -1
  55. package/src/sessions/session-callbacks.ts +23 -0
  56. package/src/shared/index.ts +1 -0
  57. package/src/{auth-email-password → shared}/token-burn-store.ts +1 -1
@@ -280,3 +280,71 @@ describe("seo :: runSeoBootCheck (direct unit-tests)", () => {
280
280
  }
281
281
  });
282
282
  });
283
+
284
+ // resolverTrust: "authoritative" (kumiko-platform#278/1, show-pony#51) —
285
+ // dieselbe Regression wie managed-pages' Suite, für sitemap.xml/llms.txt.
286
+ // gatherEntries' interner Self-Fetch für die by-tenant-published-Query trug
287
+ // keinen Host-Header; ein fallback-freier Resolver (wie hier, wie
288
+ // publicstatus' echter Resolver) löste dafür null auf → tenant_required
289
+ // statt der Managed-Pages-Einträge. systemQuery behebt das (siehe
290
+ // managed-pages' Suite für die volle Mechanismus-Erklärung + Rot/Grün-Beweis).
291
+ describe("seo :: resolverTrust authoritative (host-based, wie publicstatus/show-pony)", () => {
292
+ let authStack: TestStack;
293
+ const authManaged = createManagedPagesFeature({
294
+ resolveApexTenant: (host) => (host.startsWith("a.") ? TENANT_A : null),
295
+ });
296
+ const authSeo = createSeoFeature({
297
+ sitemapEntries: (host) => [{ loc: `https://${host}/`, changefreq: "daily" }],
298
+ managedPages: {
299
+ resolveApexTenant: (host) => (host.startsWith("a.") ? TENANT_A : null),
300
+ },
301
+ });
302
+ const authConfigFeature = createConfigFeature();
303
+
304
+ beforeAll(async () => {
305
+ const resolver = createConfigResolver();
306
+ authStack = await setupTestStack({
307
+ features: [authConfigFeature, authManaged, authSeo],
308
+ anonymousAccess: {
309
+ // KEIN URL-Fallback — spiegelt publicstatus' echten Resolver. Ein
310
+ // interner Self-Fetch trägt keinen Host-Header; mit Fallback würde
311
+ // dieser Test den Bug maskieren (siehe managed-pages' Suite).
312
+ tenantResolver: (c) => ((c.req.header("host") ?? "").startsWith("a.") ? TENANT_A : null),
313
+ resolverTrust: "authoritative",
314
+ tenantExists: async (id) => id === TENANT_A,
315
+ },
316
+ extraContext: ({ registry }) => ({
317
+ configResolver: resolver,
318
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
319
+ }),
320
+ });
321
+ await unsafeCreateEntityTable(authStack.db, pageEntity);
322
+ await unsafePushTables(authStack.db, { configValuesTable });
323
+ await createEventsTable(authStack.db);
324
+ await seedPage(authStack.db, {
325
+ tenantId: TENANT_A,
326
+ slug: "about",
327
+ lang: "en",
328
+ title: "About",
329
+ body: "# About",
330
+ published: true,
331
+ });
332
+ });
333
+
334
+ afterAll(async () => {
335
+ await authStack.cleanup();
336
+ });
337
+
338
+ test("sitemap.xml → 200, merges managed-pages' published slugs via systemQuery", async () => {
339
+ const res = await authStack.app.request("http://a.example.com/sitemap.xml");
340
+ expect(res.status).toBe(200);
341
+ const xml = await res.text();
342
+ expect(xml).toContain("<loc>https://a.example.com/</loc>");
343
+ expect(xml).toContain("<loc>http://a.example.com/p/about</loc>");
344
+ });
345
+
346
+ test("llms.txt → 200", async () => {
347
+ const res = await authStack.app.request("http://a.example.com/llms.txt");
348
+ expect(res.status).toBe(200);
349
+ });
350
+ });
@@ -19,11 +19,11 @@ const SEO_CACHE = { kind: "revalidate", maxAgeSeconds: 300 } as const;
19
19
  const MANAGED_PAGES_BY_TENANT_PUBLISHED_QN = "managed-pages:query:by-tenant-published";
20
20
  const SEO_CONFIG_QUERY_QN = "seo:query:config";
21
21
 
22
- // Minimal shape of the httpRoute handler's `{ app }` dep — just enough to
23
- // make an internal /api/query call. Not importing HttpRouteHandlerDeps
24
- // itself: it isn't part of the engine's public surface (only the
25
- // httpRoute-registration types are).
26
- type FetchApp = { readonly fetch: (req: Request) => Promise<Response> | Response };
22
+ // Minimal shape of the httpRoute handler's `{ systemQuery }` dep — just
23
+ // enough to force a specific tenant in-process. Not importing
24
+ // HttpRouteHandlerDeps itself: it isn't part of the engine's public surface
25
+ // (only the httpRoute-registration types are).
26
+ type SystemQueryFn = (type: string, payload: unknown, tenantId: string) => Promise<unknown>;
27
27
 
28
28
  export type ManagedPagesDiscoveryOptions = {
29
29
  /** Same per-host tenant resolver the app already passes to
@@ -76,26 +76,18 @@ const EMPTY_SEO_CONFIG: SeoConfigValues = {
76
76
  defaultOgImage: "",
77
77
  };
78
78
 
79
- function queryRequest(origin: string, tenantId: string, type: string, payload: unknown): Request {
80
- return new Request(`${origin}/api/query`, {
81
- method: "POST",
82
- headers: { "content-type": "application/json", "X-Tenant": tenantId },
83
- body: JSON.stringify({ type, payload }),
84
- });
85
- }
86
-
87
79
  // Config is decoration, not a hard dependency — a failed/unreachable read
88
80
  // degrades to empty strings (same posture as managed-pages' readBrandingResponse).
81
+ // systemQuery forces tenantId in-process — no internal X-Tenant self-fetch,
82
+ // which a host-based anonymousAccess resolver in "authoritative" mode would
83
+ // reject as a forged client override.
89
84
  async function readSeoConfig(
90
- app: FetchApp,
91
- origin: string,
85
+ systemQuery: SystemQueryFn,
92
86
  tenantId: string,
93
87
  ): Promise<SeoConfigValues> {
94
88
  try {
95
- const res = await app.fetch(queryRequest(origin, tenantId, SEO_CONFIG_QUERY_QN, {}));
96
- if (!res.ok) return EMPTY_SEO_CONFIG;
97
- const body: { data?: Partial<SeoConfigValues> } = await res.json();
98
- return { ...EMPTY_SEO_CONFIG, ...body.data };
89
+ const data = (await systemQuery(SEO_CONFIG_QUERY_QN, {}, tenantId)) as Partial<SeoConfigValues>;
90
+ return { ...EMPTY_SEO_CONFIG, ...data };
99
91
  } catch {
100
92
  return EMPTY_SEO_CONFIG;
101
93
  }
@@ -104,13 +96,13 @@ async function readSeoConfig(
104
96
  // Merges the app callback with the optional legal-pages/managed-pages
105
97
  // sources. legal-pages routes are static (no per-tenant data) — merged
106
98
  // directly from the public LEGAL_ROUTES constant. managed-pages entries need
107
- // a live query (per-tenant published slugs) — merged via app.fetch, same
99
+ // a live query (per-tenant published slugs) — merged via systemQuery, same
108
100
  // cross-feature decoupling as legal-pages' own text-content calls; a
109
101
  // failed/unreachable read degrades to "no managed-pages entries" rather than
110
102
  // failing the whole route.
111
103
  async function gatherEntries(
112
104
  opts: SeoOptions,
113
- app: FetchApp,
105
+ systemQuery: SystemQueryFn,
114
106
  origin: string,
115
107
  host: string,
116
108
  ): Promise<SitemapEntry[]> {
@@ -126,21 +118,16 @@ async function gatherEntries(
126
118
  const tenantId = await opts.managedPages.resolveApexTenant(host);
127
119
  if (tenantId) {
128
120
  try {
129
- const res = await app.fetch(
130
- queryRequest(origin, tenantId, MANAGED_PAGES_BY_TENANT_PUBLISHED_QN, {}),
131
- );
132
- if (res.ok) {
133
- const body: {
134
- data?: { pages?: readonly { slug: string; title: string; updatedAt: string }[] };
135
- } = await res.json();
136
- const basePath = opts.managedPages.basePath ?? "/p";
137
- for (const page of body.data?.pages ?? []) {
138
- entries.push({
139
- loc: `${origin}${basePath}/${page.slug}`,
140
- title: page.title,
141
- lastmod: page.updatedAt,
142
- });
143
- }
121
+ const data = (await systemQuery(MANAGED_PAGES_BY_TENANT_PUBLISHED_QN, {}, tenantId)) as {
122
+ pages?: readonly { slug: string; title: string; updatedAt: string }[];
123
+ };
124
+ const basePath = opts.managedPages.basePath ?? "/p";
125
+ for (const page of data.pages ?? []) {
126
+ entries.push({
127
+ loc: `${origin}${basePath}/${page.slug}`,
128
+ title: page.title,
129
+ lastmod: page.updatedAt,
130
+ });
144
131
  }
145
132
  } catch {
146
133
  // managed-pages unreachable/not mounted — degrade to callback-only entries.
@@ -203,9 +190,9 @@ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
203
190
  method: "GET",
204
191
  path: paths.sitemap,
205
192
  anonymous: true,
206
- handler: async (c, { app }) => {
193
+ handler: async (c, { systemQuery }) => {
207
194
  const { origin, host } = requestHost(c);
208
- const entries = await gatherEntries(opts, app, origin, host);
195
+ const entries = await gatherEntries(opts, systemQuery, origin, host);
209
196
  const xml = buildSitemapXml(entries);
210
197
  const etag = computeRevisionEtag([host, xml]);
211
198
  return cachedSecurePageResponse(c.req.raw, {
@@ -221,14 +208,14 @@ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
221
208
  method: "GET",
222
209
  path: paths.llmsTxt,
223
210
  anonymous: true,
224
- handler: async (c, { app }) => {
211
+ handler: async (c, { systemQuery }) => {
225
212
  const { origin, host } = requestHost(c);
226
213
  const tenantId = opts.managedPages
227
214
  ? ((await opts.managedPages.resolveApexTenant(host)) ?? SYSTEM_TENANT_ID)
228
215
  : SYSTEM_TENANT_ID;
229
216
  const [entries, seoConfig] = await Promise.all([
230
- gatherEntries(opts, app, origin, host),
231
- readSeoConfig(app, origin, tenantId),
217
+ gatherEntries(opts, systemQuery, origin, host),
218
+ readSeoConfig(systemQuery, tenantId),
232
219
  ]);
233
220
  const sections =
234
221
  entries.length > 0
@@ -7,7 +7,7 @@ import { revokeWrite } from "./handlers/revoke.write";
7
7
  import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
8
8
  import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
9
9
  import { userSessionEntity } from "./schema/user-session";
10
- import type { SessionMassRevoker } from "./session-callbacks";
10
+ import type { SessionAllOthersRevoker, SessionMassRevoker } from "./session-callbacks";
11
11
 
12
12
  export type SessionsFeatureOptions = {
13
13
  // A successful update on the `user` entity that changes the `passwordHash`
@@ -29,6 +29,7 @@ export type SessionsFeatureOptions = {
29
29
  };
30
30
 
31
31
  export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
32
+ export type BindRevokeAllOtherSessions = (revoker: SessionAllOthersRevoker) => void;
32
33
 
33
34
  // Reads the late-bind setter off a mounted sessions feature's exports.
34
35
  // run{Prod,Dev}App call it once the DB connection is concrete — the feature
@@ -33,6 +33,16 @@ const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
33
33
  // caller can log "revoked N other sessions".
34
34
  export type SessionMassRevoker = (userId: string) => Promise<number>;
35
35
 
36
+ // "Sign out everywhere else, keep this one" — used both by the user-facing
37
+ // revoke-all-others handler and by other features (auth-mfa) that need the
38
+ // same effect from a raw callback instead of a dispatcher round-trip.
39
+ // currentSid undefined (stateless-JWT / no sid claim) revokes everything —
40
+ // there is no "current" row to spare.
41
+ export type SessionAllOthersRevoker = (
42
+ userId: string,
43
+ currentSid: string | undefined,
44
+ ) => Promise<number>;
45
+
36
46
  export type SessionCallbacksOptions = {
37
47
  readonly db: DbConnection;
38
48
  // Session lifetime. MVP uses a single flat window; per-app policies can
@@ -45,6 +55,7 @@ export type SessionCallbacks = {
45
55
  sessionRevoker: SessionRevoker;
46
56
  sessionChecker: SessionChecker;
47
57
  sessionMassRevoker: SessionMassRevoker;
58
+ sessionRevokeAllOthers: SessionAllOthersRevoker;
48
59
  };
49
60
 
50
61
  export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCallbacks {
@@ -135,5 +146,17 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
135
146
  );
136
147
  return result.length;
137
148
  },
149
+
150
+ async sessionRevokeAllOthers(userId: string, currentSid: string | undefined): Promise<number> {
151
+ const result = await updateMany(
152
+ db,
153
+ userSessionTable,
154
+ { revokedAt: Temporal.Now.instant() },
155
+ currentSid
156
+ ? { userId, revokedAt: null, id: { ne: currentSid } }
157
+ : { userId, revokedAt: null },
158
+ );
159
+ return result.length;
160
+ },
138
161
  };
139
162
  }
@@ -9,3 +9,4 @@ export { decryptStoredPii } from "./decrypt-stored-pii";
9
9
  export { encryptForDirectWrite } from "./encrypt-for-direct-write";
10
10
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
11
11
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
12
+ export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
@@ -1,5 +1,5 @@
1
1
  // Single-use enforcement for HMAC-signed auth tokens (password-reset,
2
- // email-verification).
2
+ // email-verification, MFA setup/challenge).
3
3
  //
4
4
  // Problem: the token itself carries only userId + expiry + signature.
5
5
  // Without server-side burn, the same token can be replayed within its