@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.
- package/package.json +12 -7
- package/src/auth-email-password/feature.ts +6 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/login.write.ts +66 -9
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +15 -5
- package/src/auth-email-password/web/__tests__/test-utils.tsx +12 -2
- package/src/auth-email-password/web/auth-client.ts +34 -12
- package/src/auth-email-password/web/auth-gate.tsx +32 -3
- package/src/auth-email-password/web/client-plugin.ts +11 -2
- package/src/auth-email-password/web/index.ts +4 -2
- package/src/auth-email-password/web/login-screen.tsx +31 -2
- package/src/auth-email-password/web/session.tsx +11 -6
- package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +202 -0
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +150 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +167 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +168 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +186 -0
- package/src/auth-mfa/config.ts +26 -0
- package/src/auth-mfa/constants.ts +25 -0
- package/src/auth-mfa/db/queries.ts +34 -0
- package/src/auth-mfa/errors.ts +73 -0
- package/src/auth-mfa/feature.ts +146 -0
- package/src/auth-mfa/handlers/disable.write.ts +51 -0
- package/src/auth-mfa/handlers/enable-confirm.write.ts +68 -0
- package/src/auth-mfa/handlers/enable-start.write.ts +66 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +185 -0
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +62 -0
- package/src/auth-mfa/handlers/verify.write.ts +141 -0
- package/src/auth-mfa/i18n.ts +12 -0
- package/src/auth-mfa/index.ts +15 -0
- package/src/auth-mfa/mfa-challenge-token.ts +90 -0
- package/src/auth-mfa/mfa-setup-token.ts +90 -0
- package/src/auth-mfa/mfa-status-checker.ts +75 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +90 -0
- package/src/auth-mfa/recovery-codes.ts +44 -0
- package/src/auth-mfa/schema/user-mfa.ts +40 -0
- package/src/auth-mfa/totp.ts +8 -0
- package/src/auth-mfa/verify-factor.ts +30 -0
- package/src/auth-mfa/web/client-plugin.ts +37 -0
- package/src/auth-mfa/web/i18n.ts +69 -0
- package/src/auth-mfa/web/index.ts +15 -0
- package/src/auth-mfa/web/mfa-client.ts +60 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +187 -0
- package/src/auth-mfa/web/mfa-verify-screen.tsx +106 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +132 -0
- package/src/auth-mfa-user-data/hooks.ts +53 -0
- package/src/auth-mfa-user-data/index.ts +21 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +4 -0
- package/src/legal-pages/feature.ts +19 -28
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +111 -0
- package/src/managed-pages/feature.ts +44 -44
- package/src/seo/__tests__/seo.integration.test.ts +68 -0
- package/src/seo/feature.ts +28 -41
- package/src/sessions/feature.ts +2 -1
- package/src/sessions/session-callbacks.ts +23 -0
- package/src/shared/index.ts +1 -0
- 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
|
+
});
|
package/src/seo/feature.ts
CHANGED
|
@@ -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 `{
|
|
23
|
-
//
|
|
24
|
-
// itself: it isn't part of the engine's public surface
|
|
25
|
-
// httpRoute-registration types are).
|
|
26
|
-
type
|
|
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
|
-
|
|
91
|
-
origin: string,
|
|
85
|
+
systemQuery: SystemQueryFn,
|
|
92
86
|
tenantId: string,
|
|
93
87
|
): Promise<SeoConfigValues> {
|
|
94
88
|
try {
|
|
95
|
-
const
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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, {
|
|
193
|
+
handler: async (c, { systemQuery }) => {
|
|
207
194
|
const { origin, host } = requestHost(c);
|
|
208
|
-
const entries = await gatherEntries(opts,
|
|
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, {
|
|
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,
|
|
231
|
-
readSeoConfig(
|
|
217
|
+
gatherEntries(opts, systemQuery, origin, host),
|
|
218
|
+
readSeoConfig(systemQuery, tenantId),
|
|
232
219
|
]);
|
|
233
220
|
const sections =
|
|
234
221
|
entries.length > 0
|
package/src/sessions/feature.ts
CHANGED
|
@@ -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
|
}
|
package/src/shared/index.ts
CHANGED
|
@@ -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
|