@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.160.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 +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
- package/src/config/__tests__/cascade.integration.test.ts +39 -0
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +23 -8
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// RequestAccountUnlockScreen — form with an email input. Submit triggers
|
|
3
|
+
// /api/auth/request-account-unlock (silent-success, no account
|
|
4
|
+
// enumeration). UI unconditionally shows an "if your account is locked,
|
|
5
|
+
// a mail is on its way" confirmation — even when the server internally
|
|
6
|
+
// knows the email doesn't exist or isn't locked. Mirrors
|
|
7
|
+
// ForgotPasswordScreen 1:1.
|
|
8
|
+
//
|
|
9
|
+
// The app is responsible for mounting this screen under a URL (e.g.
|
|
10
|
+
// /unlock-account) and for reaching it — LoginScreen can render a link
|
|
11
|
+
// to the app route on accountLocked errors.
|
|
12
|
+
|
|
13
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
14
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
15
|
+
import { requestAccountUnlock } from "./auth-client";
|
|
16
|
+
import { AuthCard } from "./auth-form-primitives";
|
|
17
|
+
|
|
18
|
+
export type RequestAccountUnlockScreenProps = {
|
|
19
|
+
readonly title?: string;
|
|
20
|
+
readonly subtitle?: ReactNode;
|
|
21
|
+
/** href for the "back to sign in" link in success + form state. App-
|
|
22
|
+
* specific — default "/login". */
|
|
23
|
+
readonly loginHref?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function RequestAccountUnlockScreen({
|
|
27
|
+
title,
|
|
28
|
+
subtitle,
|
|
29
|
+
loginHref = "/login",
|
|
30
|
+
}: RequestAccountUnlockScreenProps): ReactNode {
|
|
31
|
+
const t = useTranslation();
|
|
32
|
+
const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
|
|
33
|
+
const [email, setEmail] = useState("");
|
|
34
|
+
const [submitting, setSubmitting] = useState(false);
|
|
35
|
+
const [done, setDone] = useState(false);
|
|
36
|
+
const [error, setError] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
// guard:dup-ok — same submit pattern as forgot-password-screen, but different API endpoint and state
|
|
39
|
+
const doSubmit = async (): Promise<void> => {
|
|
40
|
+
setSubmitting(true);
|
|
41
|
+
setError(null);
|
|
42
|
+
try {
|
|
43
|
+
const res = await requestAccountUnlock(email);
|
|
44
|
+
if (res.ok) {
|
|
45
|
+
setDone(true);
|
|
46
|
+
} else if (res.error.reason === "rate_limited") {
|
|
47
|
+
const minutes =
|
|
48
|
+
res.error.retryAfterSeconds !== undefined
|
|
49
|
+
? Math.ceil(res.error.retryAfterSeconds / 60)
|
|
50
|
+
: undefined;
|
|
51
|
+
setError(
|
|
52
|
+
minutes !== undefined
|
|
53
|
+
? t("auth.errors.accountLockedRetry", { minutes })
|
|
54
|
+
: t("auth.errors.rateLimited"),
|
|
55
|
+
);
|
|
56
|
+
} else {
|
|
57
|
+
setError(t("auth.errors.unknownError"));
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
setError(t("auth.errors.unknownError"));
|
|
61
|
+
} finally {
|
|
62
|
+
setSubmitting(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const onSubmit = (e?: FormEvent): void => {
|
|
67
|
+
e?.preventDefault();
|
|
68
|
+
void doSubmit();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const effectiveTitle = title ?? t("auth.requestUnlock.title");
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<AuthCard title={effectiveTitle} subtitle={subtitle}>
|
|
75
|
+
{done ? (
|
|
76
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
77
|
+
<Banner variant="info">
|
|
78
|
+
<p className="font-medium text-foreground">{t("auth.requestUnlock.successTitle")}</p>
|
|
79
|
+
<p className="mt-1">{t("auth.requestUnlock.successBody")}</p>
|
|
80
|
+
</Banner>
|
|
81
|
+
<Link href={loginHref} variant="muted">
|
|
82
|
+
{t("auth.requestUnlock.backToLogin")}
|
|
83
|
+
</Link>
|
|
84
|
+
</div>
|
|
85
|
+
) : (
|
|
86
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
87
|
+
<p className="text-sm text-muted-foreground">{t("auth.requestUnlock.intro")}</p>
|
|
88
|
+
<Form onSubmit={onSubmit}>
|
|
89
|
+
<Field id="unlock-email" label={t("auth.requestUnlock.email")} required>
|
|
90
|
+
<Input
|
|
91
|
+
kind="text"
|
|
92
|
+
id="unlock-email"
|
|
93
|
+
name="unlock-email"
|
|
94
|
+
value={email}
|
|
95
|
+
onChange={setEmail}
|
|
96
|
+
disabled={submitting}
|
|
97
|
+
required
|
|
98
|
+
/>
|
|
99
|
+
</Field>
|
|
100
|
+
{error !== null && <Banner variant="error">{error}</Banner>}
|
|
101
|
+
<Button type="submit" loading={submitting} disabled={submitting}>
|
|
102
|
+
{submitting ? t("auth.requestUnlock.submitting") : t("auth.requestUnlock.submit")}
|
|
103
|
+
</Button>
|
|
104
|
+
</Form>
|
|
105
|
+
<Link href={loginHref} variant="muted" className="self-center">
|
|
106
|
+
{t("auth.requestUnlock.backToLogin")}
|
|
107
|
+
</Link>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</AuthCard>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// feature.ts contract tests — pin the public surface of the Plugin-API-
|
|
2
|
+
// shaped auth-foundation. Provider-specific verification logic is tested in
|
|
3
|
+
// each provider-feature's own __tests__.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { authFoundationFeature } from "../feature";
|
|
7
|
+
import { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER } from "../types";
|
|
8
|
+
|
|
9
|
+
describe("authFoundationFeature — shape", () => {
|
|
10
|
+
test("has the expected name", () => {
|
|
11
|
+
expect(authFoundationFeature.name).toBe("auth-foundation");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("has no hard requirements — no config-key, unlike file/mail-foundation", () => {
|
|
15
|
+
expect(authFoundationFeature.requires).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("authFoundationFeature — registers extension-point", () => {
|
|
20
|
+
test("declares the tokenVerifier extension-point", () => {
|
|
21
|
+
expect(authFoundationFeature.registrarExtensions[EXT_TOKEN_VERIFIER]).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("declares the sessionStore extension-point", () => {
|
|
25
|
+
expect(authFoundationFeature.registrarExtensions[EXT_SESSION_STORE]).toBeDefined();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("authFoundationFeature — multiplicity boot-check", () => {
|
|
30
|
+
test("registers a bootCheck per extension-point (tokenVerifier + sessionStore)", () => {
|
|
31
|
+
expect(authFoundationFeature.bootChecks.length).toBeGreaterThanOrEqual(2);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// resolveSessionStore + the sessionStore multiplicity boot-check (#1370).
|
|
2
|
+
// Mock providers registered via r.useExtension(EXT_SESSION_STORE, ...) in
|
|
3
|
+
// throwaway test-features — no real sessions provider exists yet.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { validateSessionStoreMultiplicity } from "../boot-checks";
|
|
9
|
+
import { authFoundationFeature, resolveSessionStore } from "../feature";
|
|
10
|
+
import { EXT_SESSION_STORE, type SessionStore, type SessionStoreProvider } from "../types";
|
|
11
|
+
|
|
12
|
+
const fakeDb = {} as DbConnection; // @cast-boundary test fixture, unused by mock providers
|
|
13
|
+
|
|
14
|
+
function mockStore(): SessionStore {
|
|
15
|
+
return {
|
|
16
|
+
creator: async () => "sid-1",
|
|
17
|
+
revoker: async () => undefined,
|
|
18
|
+
checker: async () => "live",
|
|
19
|
+
massRevoker: async () => 0,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function storeProvider(entityName = "mock-store"): ReturnType<typeof defineFeature> {
|
|
24
|
+
const plugin: SessionStoreProvider = { build: () => mockStore() };
|
|
25
|
+
return defineFeature(`mock-${entityName}`, (r) => {
|
|
26
|
+
r.useExtension(EXT_SESSION_STORE, entityName, plugin);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("resolveSessionStore — finds the single registered provider", () => {
|
|
31
|
+
test("returns the built SessionStore", async () => {
|
|
32
|
+
const registry = createRegistry([authFoundationFeature, storeProvider()]);
|
|
33
|
+
const store = await resolveSessionStore({ db: fakeDb, registry });
|
|
34
|
+
expect(await store.checker("sid-1", "u1")).toBe("live");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("no provider registered → throws", async () => {
|
|
38
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
39
|
+
await expect(resolveSessionStore({ db: fakeDb, registry })).rejects.toThrow(
|
|
40
|
+
/no sessionStore provider registered/,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("validateSessionStoreMultiplicity", () => {
|
|
46
|
+
test("0 providers registered → throws (fail-fast, nothing to store sessions in)", () => {
|
|
47
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
48
|
+
expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).toThrow(
|
|
49
|
+
/no sessionStore provider registered/,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("exactly 1 provider → no throw", () => {
|
|
54
|
+
const registry = createRegistry([authFoundationFeature, storeProvider()]);
|
|
55
|
+
expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).not.toThrow();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("≥2 providers → throws a conflict error", () => {
|
|
59
|
+
const registry = createRegistry([
|
|
60
|
+
authFoundationFeature,
|
|
61
|
+
storeProvider("store-a"),
|
|
62
|
+
storeProvider("store-b"),
|
|
63
|
+
]);
|
|
64
|
+
expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).toThrow(
|
|
65
|
+
/2 sessionStore providers registered/,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("malformed registration (no build) → throws a malformed-plugin error", () => {
|
|
70
|
+
const broken = defineFeature("mock-broken", (r) => {
|
|
71
|
+
r.useExtension(EXT_SESSION_STORE, "broken", {});
|
|
72
|
+
});
|
|
73
|
+
const registry = createRegistry([authFoundationFeature, broken]);
|
|
74
|
+
expect(() => validateSessionStoreMultiplicity([...registry.features.values()])).toThrow(
|
|
75
|
+
/without a valid SessionStoreProvider/,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// resolveTokenVerifier + the tokenVerifier multiplicity boot-check (#1368).
|
|
2
|
+
// Mock providers registered via r.useExtension(EXT_TOKEN_VERIFIER, ...) in
|
|
3
|
+
// throwaway test-features — no real JWT/PAT provider exists yet.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { validateTokenVerifierMultiplicity } from "../boot-checks";
|
|
10
|
+
import { authFoundationFeature, resolveTokenVerifier } from "../feature";
|
|
11
|
+
import { type AuthProviderPlugin, EXT_TOKEN_VERIFIER } from "../types";
|
|
12
|
+
|
|
13
|
+
// Mock providers never read `deps.db` — a real provider (personal-access-
|
|
14
|
+
// tokens/resolver.ts) does a point-read + live-role-resolution against it.
|
|
15
|
+
const fakeDb = {} as DbConnection; // @cast-boundary test fixture, unused by mock providers
|
|
16
|
+
|
|
17
|
+
function jwtLikeUser(): SessionUser {
|
|
18
|
+
return { id: "u-jwt", tenantId: "t1", roles: ["User"] } as SessionUser; // @cast-boundary test fixture
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function prefixProvider(entityName: string, prefix: string): ReturnType<typeof defineFeature> {
|
|
22
|
+
const plugin: AuthProviderPlugin = {
|
|
23
|
+
shape: { kind: "prefix", prefix },
|
|
24
|
+
build: () => async (rawToken) => (rawToken === `${prefix}valid` ? jwtLikeUser() : null),
|
|
25
|
+
};
|
|
26
|
+
return defineFeature(`mock-${entityName}`, (r) => {
|
|
27
|
+
r.useExtension(EXT_TOKEN_VERIFIER, entityName, plugin);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function jwtProvider(entityName = "jwt-mock"): ReturnType<typeof defineFeature> {
|
|
32
|
+
const plugin: AuthProviderPlugin = {
|
|
33
|
+
shape: { kind: "jwt" },
|
|
34
|
+
build: () => async () => jwtLikeUser(),
|
|
35
|
+
};
|
|
36
|
+
return defineFeature(`mock-${entityName}`, (r) => {
|
|
37
|
+
r.useExtension(EXT_TOKEN_VERIFIER, entityName, plugin);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("resolveTokenVerifier — middleware finds the matching provider", () => {
|
|
42
|
+
test("routes a prefixed token to the prefix-shape provider", async () => {
|
|
43
|
+
const registry = createRegistry([authFoundationFeature, prefixProvider("pat-mock", "kpat_")]);
|
|
44
|
+
const user = await resolveTokenVerifier({ db: fakeDb, registry }, "kpat_valid");
|
|
45
|
+
expect(user?.id).toBe("u-jwt");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("routes a dot-separated token to the jwt-shape provider", async () => {
|
|
49
|
+
const registry = createRegistry([authFoundationFeature, jwtProvider()]);
|
|
50
|
+
const user = await resolveTokenVerifier({ db: fakeDb, registry }, "header.payload.sig");
|
|
51
|
+
expect(user?.id).toBe("u-jwt");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("no provider's shape matches → null", async () => {
|
|
55
|
+
const registry = createRegistry([authFoundationFeature, prefixProvider("pat-mock", "kpat_")]);
|
|
56
|
+
const user = await resolveTokenVerifier({ db: fakeDb, registry }, "not-a-pat-token");
|
|
57
|
+
expect(user).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("no providers registered at all → null", async () => {
|
|
61
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
62
|
+
const user = await resolveTokenVerifier({ db: fakeDb, registry }, "anything");
|
|
63
|
+
expect(user).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("validateTokenVerifierMultiplicity", () => {
|
|
68
|
+
test("0 providers registered → throws (fail-fast, no provider to route to)", () => {
|
|
69
|
+
const registry = createRegistry([authFoundationFeature]);
|
|
70
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).toThrow(
|
|
71
|
+
/no tokenVerifier providers registered/,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("1 provider per shape → no throw", () => {
|
|
76
|
+
const registry = createRegistry([
|
|
77
|
+
authFoundationFeature,
|
|
78
|
+
jwtProvider(),
|
|
79
|
+
prefixProvider("pat-mock", "kpat_"),
|
|
80
|
+
]);
|
|
81
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).not.toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("≥2 providers claiming the same shape → throws a conflict error", () => {
|
|
85
|
+
const registry = createRegistry([
|
|
86
|
+
authFoundationFeature,
|
|
87
|
+
jwtProvider("jwt-mock-a"),
|
|
88
|
+
jwtProvider("jwt-mock-b"),
|
|
89
|
+
]);
|
|
90
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).toThrow(
|
|
91
|
+
/same shape "jwt"/,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("wrong-shape registration → throws a malformed-plugin error", () => {
|
|
96
|
+
const broken = defineFeature("mock-broken", (r) => {
|
|
97
|
+
r.useExtension(EXT_TOKEN_VERIFIER, "broken", { shape: { kind: "prefix" } });
|
|
98
|
+
});
|
|
99
|
+
const registry = createRegistry([authFoundationFeature, broken]);
|
|
100
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).toThrow(
|
|
101
|
+
/without a valid AuthProviderPlugin/,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("malformed shape (build present, prefix missing) → throws a malformed-plugin error", () => {
|
|
106
|
+
const broken = defineFeature("mock-broken-shape", (r) => {
|
|
107
|
+
r.useExtension(EXT_TOKEN_VERIFIER, "broken-shape", {
|
|
108
|
+
shape: { kind: "prefix" },
|
|
109
|
+
build: () => async () => null,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
const registry = createRegistry([authFoundationFeature, broken]);
|
|
113
|
+
expect(() => validateTokenVerifierMultiplicity([...registry.features.values()])).toThrow(
|
|
114
|
+
/without a valid AuthProviderPlugin/,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Multiplicity boot-check for auth-foundation's `tokenVerifier` extension
|
|
2
|
+
// point (#1368). Two static-shape conflicts a boot-check CAN catch (a
|
|
3
|
+
// runtime shape-match predicate's overlap can't be proven at boot): a
|
|
4
|
+
// malformed plugin registration, and two providers both claiming the same
|
|
5
|
+
// shape (resolveTokenVerifier can't tell them apart). Zero registered
|
|
6
|
+
// providers is ALSO caught here — fail-fast at boot ("you mounted the
|
|
7
|
+
// foundation but forgot a provider") rather than a runtime 401 nobody
|
|
8
|
+
// can attribute, unlike file-foundation/mail-foundation which defer that
|
|
9
|
+
// check to request-time (their provider is picked from tenant config,
|
|
10
|
+
// which doesn't exist at boot; auth-foundation's providers are static
|
|
11
|
+
// per-deployment, so boot is the right time to catch it).
|
|
12
|
+
|
|
13
|
+
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import {
|
|
15
|
+
EXT_SESSION_STORE,
|
|
16
|
+
EXT_TOKEN_VERIFIER,
|
|
17
|
+
isAuthProviderPlugin,
|
|
18
|
+
isSessionStoreProvider,
|
|
19
|
+
tokenShapeKey,
|
|
20
|
+
} from "./types";
|
|
21
|
+
|
|
22
|
+
export function validateTokenVerifierMultiplicity(features: readonly FeatureDefinition[]): void {
|
|
23
|
+
const namesByShape = new Map<string, string[]>();
|
|
24
|
+
|
|
25
|
+
for (const feature of features) {
|
|
26
|
+
for (const usage of feature.extensionUsages) {
|
|
27
|
+
if (usage.extensionName !== EXT_TOKEN_VERIFIER) continue;
|
|
28
|
+
if (!isAuthProviderPlugin(usage.options)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`[auth-foundation] tokenVerifier provider "${usage.entityName}" (feature "${feature.name}") ` +
|
|
31
|
+
`registered without a valid AuthProviderPlugin — options must have a { shape, build } shape.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const key = tokenShapeKey(usage.options.shape);
|
|
35
|
+
const names = namesByShape.get(key) ?? [];
|
|
36
|
+
names.push(usage.entityName);
|
|
37
|
+
namesByShape.set(key, names);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (namesByShape.size === 0) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
"[auth-foundation] no tokenVerifier providers registered — mount at least one " +
|
|
44
|
+
"auth-provider-* feature (e.g. auth-provider-jwt) alongside auth-foundation.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const [key, names] of namesByShape) {
|
|
49
|
+
if (names.length >= 2) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[auth-foundation] ${names.length} tokenVerifier providers claim the same shape "${key}" ` +
|
|
52
|
+
`(${names.join(", ")}) — resolveTokenVerifier can't route between them unambiguously. ` +
|
|
53
|
+
`Give each provider a distinct shape.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Multiplicity boot-check for the `sessionStore` extension point (#1370).
|
|
60
|
+
// Single-provider, unlike tokenVerifier — no shape to route on, so exactly
|
|
61
|
+
// one registration is required; 0 or ≥2 both fail boot.
|
|
62
|
+
export function validateSessionStoreMultiplicity(features: readonly FeatureDefinition[]): void {
|
|
63
|
+
const names: string[] = [];
|
|
64
|
+
|
|
65
|
+
for (const feature of features) {
|
|
66
|
+
for (const usage of feature.extensionUsages) {
|
|
67
|
+
if (usage.extensionName !== EXT_SESSION_STORE) continue;
|
|
68
|
+
if (!isSessionStoreProvider(usage.options)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`[auth-foundation] sessionStore provider "${usage.entityName}" (feature "${feature.name}") ` +
|
|
71
|
+
`registered without a valid SessionStoreProvider — options must have a { build } shape.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
names.push(usage.entityName);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (names.length === 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"[auth-foundation] no sessionStore provider registered — mount a feature that " +
|
|
81
|
+
"registers one via r.useExtension(EXT_SESSION_STORE, ...) alongside auth-foundation.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (names.length >= 2) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[auth-foundation] ${names.length} sessionStore providers registered (${names.join(", ")}) — ` +
|
|
88
|
+
`only one sessionStore provider may be mounted at a time.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// auth-foundation — declares the `tokenVerifier` extension point for Bearer-
|
|
4
|
+
// token verification. Provider-features (auth-provider-jwt, auth-provider-
|
|
5
|
+
// pat, ...) register via `r.useExtension(EXT_TOKEN_VERIFIER, "<name>", plugin)`.
|
|
6
|
+
//
|
|
7
|
+
// **Scaffold only:** not wired into `api/auth-middleware.ts` yet —
|
|
8
|
+
// personal-access-tokens still uses its own direct `patResolver` callback
|
|
9
|
+
// (kumiko-framework#745). A future issue migrates the middleware onto this
|
|
10
|
+
// extension point; #1368 only lays the groundwork (PAT-Plan-Doc Phase-1-
|
|
11
|
+
// Sub-Step-1).
|
|
12
|
+
//
|
|
13
|
+
// **Pattern precedent:** file-foundation/mail-foundation for the extension-
|
|
14
|
+
// point + plugin-registry shape. Diverges on ROUTING: those pick one active
|
|
15
|
+
// provider per tenant via a config-key; auth-foundation has no tenant yet
|
|
16
|
+
// at verify-time, so providers are tried in shape-match order instead (see
|
|
17
|
+
// types.ts).
|
|
18
|
+
|
|
19
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
import { validateSessionStoreMultiplicity, validateTokenVerifierMultiplicity } from "./boot-checks";
|
|
22
|
+
import {
|
|
23
|
+
type AuthProviderBuildDeps,
|
|
24
|
+
EXT_SESSION_STORE,
|
|
25
|
+
EXT_TOKEN_VERIFIER,
|
|
26
|
+
isAuthProviderPlugin,
|
|
27
|
+
isSessionStoreProvider,
|
|
28
|
+
type SessionStore,
|
|
29
|
+
tokenShapeMatches,
|
|
30
|
+
} from "./types";
|
|
31
|
+
|
|
32
|
+
export { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER } from "./types";
|
|
33
|
+
|
|
34
|
+
const FEATURE_NAME = "auth-foundation";
|
|
35
|
+
|
|
36
|
+
export const authFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
37
|
+
r.describe(
|
|
38
|
+
"Declares the `tokenVerifier` (Bearer-token auth) and `sessionStore` extension points. Provider-features register a static `shape` plus a `build()` for tokenVerifier — mount together with at least one auth-provider-* feature (e.g. personal-access-tokens) and call `resolveTokenVerifier(deps, rawToken)` to find the matching one; sessions self-registers as the single sessionStore provider.",
|
|
39
|
+
);
|
|
40
|
+
r.uiHints({
|
|
41
|
+
displayLabel: "Auth Provider Foundation",
|
|
42
|
+
category: "identity",
|
|
43
|
+
recommended: false,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
r.extendsRegistrar(EXT_TOKEN_VERIFIER, {
|
|
47
|
+
onRegister: () => {
|
|
48
|
+
// No side-effects at register-time — registry stores the usage,
|
|
49
|
+
// resolveTokenVerifier looks it up at request-time.
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Owns EXT_TOKEN_VERIFIER, so its own mount is the trigger — same
|
|
54
|
+
// convention as tenant-lifecycle/user-data-rights' self-bootCheck.
|
|
55
|
+
r.bootCheck(({ features }) => validateTokenVerifierMultiplicity(features));
|
|
56
|
+
|
|
57
|
+
r.extendsRegistrar(EXT_SESSION_STORE, {
|
|
58
|
+
onRegister: () => {
|
|
59
|
+
// No side-effects at register-time — registry stores the usage,
|
|
60
|
+
// resolveSessionStore looks it up at request-time.
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
r.bootCheck(({ features }) => validateSessionStoreMultiplicity(features));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Tries every registered tokenVerifier provider's shape against `rawToken`
|
|
69
|
+
* in registration order and calls the first match's verifier. Returns null
|
|
70
|
+
* when no provider's shape matches, or when the matching provider's
|
|
71
|
+
* verifier rejects the token.
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveTokenVerifier(
|
|
74
|
+
deps: AuthProviderBuildDeps & { readonly registry: Registry },
|
|
75
|
+
rawToken: string,
|
|
76
|
+
): Promise<SessionUser | null> {
|
|
77
|
+
const usages = deps.registry.getExtensionUsages(EXT_TOKEN_VERIFIER);
|
|
78
|
+
for (const usage of usages) {
|
|
79
|
+
if (!isAuthProviderPlugin(usage.options)) continue;
|
|
80
|
+
if (!tokenShapeMatches(usage.options.shape, rawToken)) continue;
|
|
81
|
+
const verify = await usage.options.build(deps);
|
|
82
|
+
return verify(rawToken);
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves the single registered sessionStore provider. Assumes
|
|
89
|
+
* validateSessionStoreMultiplicity already ran at boot — a missing provider
|
|
90
|
+
* here means the app booted without going through the framework's boot
|
|
91
|
+
* validator, not a runtime state resolveSessionStore should model.
|
|
92
|
+
*/
|
|
93
|
+
export async function resolveSessionStore(
|
|
94
|
+
deps: AuthProviderBuildDeps & { readonly registry: Registry },
|
|
95
|
+
): Promise<SessionStore> {
|
|
96
|
+
const usage = deps.registry.getExtensionUsages(EXT_SESSION_STORE)[0];
|
|
97
|
+
if (!usage || !isSessionStoreProvider(usage.options)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"[auth-foundation] no sessionStore provider registered — did the boot validator run?",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return usage.options.build(deps);
|
|
103
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Public API of the auth-foundation bundled-feature.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
authFoundationFeature,
|
|
5
|
+
resolveSessionStore,
|
|
6
|
+
resolveTokenVerifier,
|
|
7
|
+
} from "./feature";
|
|
8
|
+
export {
|
|
9
|
+
type AuthProviderBuildDeps,
|
|
10
|
+
type AuthProviderPlugin,
|
|
11
|
+
type AuthVerifier,
|
|
12
|
+
EXT_SESSION_STORE,
|
|
13
|
+
EXT_TOKEN_VERIFIER,
|
|
14
|
+
isAuthProviderPlugin,
|
|
15
|
+
isSessionStoreProvider,
|
|
16
|
+
isValidTokenShape,
|
|
17
|
+
type SessionMassRevoker,
|
|
18
|
+
type SessionStore,
|
|
19
|
+
type SessionStoreProvider,
|
|
20
|
+
type TokenShape,
|
|
21
|
+
tokenShapeKey,
|
|
22
|
+
tokenShapeMatches,
|
|
23
|
+
} from "./types";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// Type contracts for auth-foundation's `tokenVerifier` extension point.
|
|
4
|
+
// Provider-features (future: auth-provider-jwt, auth-provider-pat) register
|
|
5
|
+
// an AuthProviderPlugin via `r.useExtension(EXT_TOKEN_VERIFIER, "<name>", plugin)`.
|
|
6
|
+
//
|
|
7
|
+
// **Shape-match routing:** unlike file-foundation/mail-foundation (one active
|
|
8
|
+
// provider per tenant, chosen via a config-key), token verification happens
|
|
9
|
+
// BEFORE the tenant is known — there's nothing to select on. Every registered
|
|
10
|
+
// provider declares a static `shape` (how to recognize its own tokens by
|
|
11
|
+
// looking at the raw bearer value); `resolveTokenVerifier` tries each
|
|
12
|
+
// provider's shape in turn and calls the first match's verifier.
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
SessionChecker,
|
|
16
|
+
SessionCreator,
|
|
17
|
+
SessionRevoker,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
19
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
20
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
|
|
22
|
+
export const EXT_TOKEN_VERIFIER = "tokenVerifier";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Deps available to a provider's `build()`. DB access only — a hot-path
|
|
26
|
+
* resolver (see personal-access-tokens/resolver.ts) needs a raw-DB handle
|
|
27
|
+
* for a point-read + live-role-resolution, not a per-request ctx (there is
|
|
28
|
+
* no tenant/session yet at verify-time).
|
|
29
|
+
*/
|
|
30
|
+
export type AuthProviderBuildDeps = {
|
|
31
|
+
readonly db: DbConnection;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** How to recognize this provider's tokens by looking at the raw bearer value. */
|
|
35
|
+
export type TokenShape =
|
|
36
|
+
| { readonly kind: "jwt" }
|
|
37
|
+
| { readonly kind: "prefix"; readonly prefix: string };
|
|
38
|
+
|
|
39
|
+
export type AuthVerifier = (rawToken: string) => Promise<SessionUser | null>;
|
|
40
|
+
|
|
41
|
+
export type AuthProviderPlugin = {
|
|
42
|
+
readonly shape: TokenShape;
|
|
43
|
+
readonly build: (deps: AuthProviderBuildDeps) => Promise<AuthVerifier> | AuthVerifier;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function isValidTokenShape(shape: unknown): shape is TokenShape {
|
|
47
|
+
if (typeof shape !== "object" || shape === null || !("kind" in shape)) return false;
|
|
48
|
+
const kind = (shape as { kind: unknown }).kind;
|
|
49
|
+
if (kind === "jwt") return true;
|
|
50
|
+
if (kind === "prefix") {
|
|
51
|
+
const prefix = "prefix" in shape ? (shape as { prefix: unknown }).prefix : undefined;
|
|
52
|
+
return typeof prefix === "string" && prefix.length > 0;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// extension-usage `options` is engine-payload (unknown) — structurally validate
|
|
58
|
+
// instead of casting blind. Mirrors file-foundation's isFileProviderPlugin.
|
|
59
|
+
export function isAuthProviderPlugin(o: unknown): o is AuthProviderPlugin {
|
|
60
|
+
return (
|
|
61
|
+
typeof o === "object" &&
|
|
62
|
+
o !== null &&
|
|
63
|
+
"build" in o &&
|
|
64
|
+
typeof (o as { build: unknown }).build === "function" &&
|
|
65
|
+
"shape" in o &&
|
|
66
|
+
isValidTokenShape((o as { shape: unknown }).shape)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function tokenShapeMatches(shape: TokenShape, rawToken: string): boolean {
|
|
71
|
+
if (shape.kind === "prefix") return rawToken.startsWith(shape.prefix);
|
|
72
|
+
return rawToken.split(".").length === 3;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Static discriminator used by the multiplicity boot-check to detect two providers claiming the same shape. */
|
|
76
|
+
export function tokenShapeKey(shape: TokenShape): string {
|
|
77
|
+
return shape.kind === "jwt" ? "jwt" : `prefix:${shape.prefix}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Session-store extension point (#1370). Unlike EXT_TOKEN_VERIFIER, this is
|
|
81
|
+
// single-provider — sessions have no per-provider shape to route on, so
|
|
82
|
+
// exactly one implementation must be registered (boot-fails on 0 or ≥2).
|
|
83
|
+
|
|
84
|
+
export const EXT_SESSION_STORE = "sessionStore";
|
|
85
|
+
|
|
86
|
+
/** Mass-revoke every live session for a user. Used by password-change and "sign out everywhere". */
|
|
87
|
+
export type SessionMassRevoker = (userId: string) => Promise<number>;
|
|
88
|
+
|
|
89
|
+
export type SessionStore = {
|
|
90
|
+
readonly creator: SessionCreator;
|
|
91
|
+
readonly revoker: SessionRevoker;
|
|
92
|
+
readonly checker: SessionChecker;
|
|
93
|
+
readonly massRevoker: SessionMassRevoker;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type SessionStoreProvider = {
|
|
97
|
+
readonly build: (deps: AuthProviderBuildDeps) => Promise<SessionStore> | SessionStore;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export function isSessionStoreProvider(o: unknown): o is SessionStoreProvider {
|
|
101
|
+
return (
|
|
102
|
+
typeof o === "object" &&
|
|
103
|
+
o !== null &&
|
|
104
|
+
"build" in o &&
|
|
105
|
+
typeof (o as { build: unknown }).build === "function"
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -28,4 +28,12 @@ describe("auth-mfa errors.ts i18nKey / client-bundle parity", () => {
|
|
|
28
28
|
expect(defaultTranslations["de"]?.[key]).toBeDefined();
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
// Client-only key: mfa-enable-screen.tsx sets this directly (not via a
|
|
33
|
+
// DispatcherError), so it isn't covered by the failures loop above.
|
|
34
|
+
test("client-only auth.mfa.errors.setupFailed is registered in en/de translations", () => {
|
|
35
|
+
const key = "auth.mfa.errors.setupFailed";
|
|
36
|
+
expect(defaultTranslations["en"]?.[key]).toBeDefined();
|
|
37
|
+
expect(defaultTranslations["de"]?.[key]).toBeDefined();
|
|
38
|
+
});
|
|
31
39
|
});
|