@cosmicdrift/kumiko-bundled-features 0.108.0 → 0.110.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 +6 -6
- package/src/auth-email-password/__tests__/feature-options.test.ts +33 -0
- package/src/auth-email-password/constants.ts +4 -0
- package/src/auth-email-password/feature.ts +8 -4
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +5 -4
- package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +10 -4
- package/src/personal-access-tokens/scopes.ts +32 -11
- package/src/personal-access-tokens/web/i18n.ts +38 -14
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +165 -64
- package/src/sessions/__tests__/auto-revoke-binding.test.ts +71 -0
- package/src/sessions/feature.ts +51 -18
- package/src/sessions/index.ts +2 -2
- package/src/user/__tests__/user.integration.test.ts +26 -0
- package/src/user/handlers/find-for-auth.query.ts +7 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.110.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>",
|
|
@@ -95,11 +95,11 @@
|
|
|
95
95
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
99
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
101
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
102
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
98
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.110.0",
|
|
99
|
+
"@cosmicdrift/kumiko-framework": "0.110.0",
|
|
100
|
+
"@cosmicdrift/kumiko-headless": "0.110.0",
|
|
101
|
+
"@cosmicdrift/kumiko-renderer": "0.110.0",
|
|
102
|
+
"@cosmicdrift/kumiko-renderer-web": "0.110.0",
|
|
103
103
|
"@mollie/api-client": "^4.5.0",
|
|
104
104
|
"@node-rs/argon2": "^2.0.2",
|
|
105
105
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MIN_HMAC_SECRET_LENGTH } from "../constants";
|
|
3
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
4
|
+
|
|
5
|
+
// A short HMAC secret makes reset/verify tokens forgeable (account takeover),
|
|
6
|
+
// so the factory must fail fast — same bar as the ≥32-char JWT_SECRET check.
|
|
7
|
+
|
|
8
|
+
const okSecret = "x".repeat(MIN_HMAC_SECRET_LENGTH);
|
|
9
|
+
const shortSecret = "x".repeat(MIN_HMAC_SECRET_LENGTH - 1);
|
|
10
|
+
const appUrl = "https://app.example.com/flow";
|
|
11
|
+
|
|
12
|
+
describe("createAuthEmailPasswordFeature hmacSecret validation", () => {
|
|
13
|
+
test("rejects a short passwordReset.hmacSecret", () => {
|
|
14
|
+
expect(() =>
|
|
15
|
+
createAuthEmailPasswordFeature({ passwordReset: { hmacSecret: shortSecret, appUrl } }),
|
|
16
|
+
).toThrow(/passwordReset\.hmacSecret must be/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("rejects a short emailVerification.hmacSecret", () => {
|
|
20
|
+
expect(() =>
|
|
21
|
+
createAuthEmailPasswordFeature({ emailVerification: { hmacSecret: shortSecret, appUrl } }),
|
|
22
|
+
).toThrow(/emailVerification\.hmacSecret must be/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("accepts secrets at the minimum length", () => {
|
|
26
|
+
expect(() =>
|
|
27
|
+
createAuthEmailPasswordFeature({
|
|
28
|
+
passwordReset: { hmacSecret: okSecret, appUrl },
|
|
29
|
+
emailVerification: { hmacSecret: okSecret, appUrl },
|
|
30
|
+
}),
|
|
31
|
+
).not.toThrow();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
// der server-side Zugriff (handlers, dispatcher) erhalten.
|
|
7
7
|
export const AUTH_EMAIL_PASSWORD_FEATURE = "auth-email-password" as const;
|
|
8
8
|
|
|
9
|
+
// Minimum length for reset/verify hmacSecret — mirrors the ≥32-char
|
|
10
|
+
// JWT_SECRET env check (HMAC-SHA256 key material).
|
|
11
|
+
export const MIN_HMAC_SECRET_LENGTH = 32;
|
|
12
|
+
|
|
9
13
|
// Qualified handler names. Non-CRUD handlers, no entity prefix.
|
|
10
14
|
export const AuthHandlers = {
|
|
11
15
|
login: "auth-email-password:write:login",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { MIN_HMAC_SECRET_LENGTH } from "./constants";
|
|
3
4
|
import type { AuthMailLocale } from "./email-templates";
|
|
4
5
|
import { changePasswordWrite } from "./handlers/change-password.write";
|
|
5
6
|
import { createInviteAcceptHandler } from "./handlers/invite-accept.write";
|
|
@@ -122,14 +123,17 @@ export type AuthEmailPasswordOptions = {
|
|
|
122
123
|
export function createAuthEmailPasswordFeature(
|
|
123
124
|
opts: AuthEmailPasswordOptions = {},
|
|
124
125
|
): FeatureDefinition {
|
|
125
|
-
|
|
126
|
+
// ≥32 chars, symmetric to the JWT_SECRET env check: a short HMAC secret
|
|
127
|
+
// makes reset/verify tokens forgeable — that's account takeover, so the
|
|
128
|
+
// factory fails fast instead of trusting the caller.
|
|
129
|
+
if (opts.passwordReset && opts.passwordReset.hmacSecret.length < MIN_HMAC_SECRET_LENGTH) {
|
|
126
130
|
throw new Error(
|
|
127
|
-
|
|
131
|
+
`[auth-email-password] passwordReset.hmacSecret must be ≥${MIN_HMAC_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
|
|
128
132
|
);
|
|
129
133
|
}
|
|
130
|
-
if (opts.emailVerification &&
|
|
134
|
+
if (opts.emailVerification && opts.emailVerification.hmacSecret.length < MIN_HMAC_SECRET_LENGTH) {
|
|
131
135
|
throw new Error(
|
|
132
|
-
|
|
136
|
+
`[auth-email-password] emailVerification.hmacSecret must be ≥${MIN_HMAC_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
|
|
133
137
|
);
|
|
134
138
|
}
|
|
135
139
|
|
|
@@ -49,10 +49,11 @@ let patResolver: PatResolver | undefined;
|
|
|
49
49
|
const encryptionKey = randomBytes(32).toString("base64");
|
|
50
50
|
const TENANT: TenantId = testTenantId(1);
|
|
51
51
|
|
|
52
|
-
// One
|
|
53
|
-
// sessions:query:user-session:mine, so that QN is the
|
|
52
|
+
// One domain "tokens" whose read set is exactly the two PAT queries —
|
|
53
|
+
// deliberately NOT sessions:query:user-session:mine, so that QN is the
|
|
54
|
+
// out-of-scope probe. Granted as "tokens:read".
|
|
54
55
|
const SCOPES: PatScopeConfig = {
|
|
55
|
-
tokens: { label: "Tokens",
|
|
56
|
+
tokens: { label: "Tokens", read: [PatQueries.mine, PatQueries.availableScopes] },
|
|
56
57
|
};
|
|
57
58
|
|
|
58
59
|
async function mintToken(
|
|
@@ -63,7 +64,7 @@ async function mintToken(
|
|
|
63
64
|
PatHandlers.create,
|
|
64
65
|
{
|
|
65
66
|
name: "test",
|
|
66
|
-
scopes: opts?.scopes ?? ["tokens"],
|
|
67
|
+
scopes: opts?.scopes ?? ["tokens:read"],
|
|
67
68
|
...(opts?.expiresInDays ? { expiresInDays: opts.expiresInDays } : {}),
|
|
68
69
|
},
|
|
69
70
|
actor,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { expandScopes, type PatScopeConfig, parseGrant } from "../scopes";
|
|
3
|
+
|
|
4
|
+
const CONFIG: PatScopeConfig = {
|
|
5
|
+
credit: { label: "Kredite", read: ["credit:query:*"], write: ["credit:write:*"] },
|
|
6
|
+
miete: { label: "Mieten", read: ["ledger:query:*"] }, // read-only domain
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe("parseGrant", () => {
|
|
10
|
+
it("splits domain:level on the last colon", () => {
|
|
11
|
+
expect(parseGrant("credit:write")).toEqual({ domain: "credit", level: "write" });
|
|
12
|
+
});
|
|
13
|
+
it("rejects a bare token", () => {
|
|
14
|
+
expect(parseGrant("credit")).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("expandScopes", () => {
|
|
19
|
+
it("read grants only the read QNs", () => {
|
|
20
|
+
expect(expandScopes(CONFIG, ["credit:read"])).toEqual(["credit:query:*"]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("write grants read + write QNs", () => {
|
|
24
|
+
expect(expandScopes(CONFIG, ["credit:write"]).sort()).toEqual(
|
|
25
|
+
["credit:query:*", "credit:write:*"].sort(),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("read-only domain ignores a write grant's missing write set", () => {
|
|
30
|
+
expect(expandScopes(CONFIG, ["miete:write"])).toEqual(["ledger:query:*"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("unknown domain contributes nothing (fail-closed)", () => {
|
|
34
|
+
expect(expandScopes(CONFIG, ["ghost:write"])).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("unions across multiple grants without duplicates", () => {
|
|
38
|
+
expect(expandScopes(CONFIG, ["credit:read", "miete:read"]).sort()).toEqual(
|
|
39
|
+
["credit:query:*", "ledger:query:*"].sort(),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -2,14 +2,20 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { PatScopeConfig } from "../scopes";
|
|
4
4
|
|
|
5
|
-
// Returns the app-declared
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// Returns the app-declared scope domains ({name, label, canWrite}) so the mint
|
|
6
|
+
// UI can render a per-domain level picker (no access / read / read & write).
|
|
7
|
+
// canWrite is false for read-only domains → the UI hides the write option.
|
|
8
|
+
// Static per deployment, not per user.
|
|
8
9
|
export function buildAvailableScopesQuery(scopes: PatScopeConfig) {
|
|
9
10
|
return defineQueryHandler({
|
|
10
11
|
name: "available-scopes",
|
|
11
12
|
schema: z.object({}),
|
|
12
13
|
access: { openToAll: true },
|
|
13
|
-
handler: async () =>
|
|
14
|
+
handler: async () =>
|
|
15
|
+
Object.entries(scopes).map(([name, def]) => ({
|
|
16
|
+
name,
|
|
17
|
+
label: def.label,
|
|
18
|
+
canWrite: (def.write?.length ?? 0) > 0,
|
|
19
|
+
})),
|
|
14
20
|
});
|
|
15
21
|
}
|
|
@@ -1,22 +1,43 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Pure scope helpers + types — client-marked so the web screen (web/) may import
|
|
3
|
+
// parseGrant without pulling the feature's server runtime barrel.
|
|
4
|
+
//
|
|
5
|
+
// App-declared scopes — two axes, like GitHub fine-grained PATs: WHICH API
|
|
6
|
+
// (the domain, keyed here) × the permission LEVEL (read vs read+write). Each
|
|
7
|
+
// domain declares its read-QN globs and (optionally) its write-QN globs. A
|
|
8
|
+
// domain may span features (e.g. a "miete" domain granting ledger + folders).
|
|
5
9
|
export type PatScopeDef = {
|
|
6
10
|
readonly label: string;
|
|
7
|
-
readonly
|
|
11
|
+
readonly read: readonly string[];
|
|
12
|
+
// Omit for a read-only domain — the UI then offers only "no access" / "read".
|
|
13
|
+
readonly write?: readonly string[];
|
|
8
14
|
};
|
|
9
15
|
|
|
10
16
|
export type PatScopeConfig = Readonly<Record<string, PatScopeDef>>;
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
18
|
+
export type PatLevel = "read" | "write";
|
|
19
|
+
|
|
20
|
+
// A granted scope is the string "<domain>:<level>" (e.g. "credit:write"). The
|
|
21
|
+
// domain key never contains a colon, so split on the LAST one.
|
|
22
|
+
export function parseGrant(grant: string): { domain: string; level: string } | null {
|
|
23
|
+
const idx = grant.lastIndexOf(":");
|
|
24
|
+
if (idx <= 0) return null;
|
|
25
|
+
return { domain: grant.slice(0, idx), level: grant.slice(idx + 1) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Expand granted "<domain>:<level>" entries into the union of QN globs: read
|
|
29
|
+
// always grants the read QNs; write additionally grants the write QNs. Unknown
|
|
30
|
+
// domains contribute nothing (fail-closed — a scope dropped from config after a
|
|
31
|
+
// token was minted silently loses that capability).
|
|
15
32
|
export function expandScopes(config: PatScopeConfig, granted: readonly string[]): string[] {
|
|
16
33
|
const out = new Set<string>();
|
|
17
|
-
for (const
|
|
18
|
-
const
|
|
19
|
-
if (
|
|
34
|
+
for (const grant of granted) {
|
|
35
|
+
const parsed = parseGrant(grant);
|
|
36
|
+
if (!parsed) continue;
|
|
37
|
+
const def = config[parsed.domain];
|
|
38
|
+
if (!def) continue;
|
|
39
|
+
for (const q of def.read) out.add(q);
|
|
40
|
+
if (parsed.level === "write") for (const q of def.write ?? []) out.add(q);
|
|
20
41
|
}
|
|
21
42
|
return [...out];
|
|
22
43
|
}
|
|
@@ -5,41 +5,65 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
5
5
|
de: {
|
|
6
6
|
"pat.title": "Personal Access Tokens",
|
|
7
7
|
"pat.create.title": "Neuen Token erstellen",
|
|
8
|
+
"pat.create.subtitle":
|
|
9
|
+
"Wähle Berechtigungen und eine Gültigkeit. Der Token wird nur einmal angezeigt.",
|
|
8
10
|
"pat.create.name": "Name",
|
|
9
11
|
"pat.create.namePlaceholder": "z. B. CLI-Automatisierung",
|
|
10
|
-
"pat.create.scopes": "
|
|
12
|
+
"pat.create.scopes": "Zugriff pro Bereich",
|
|
13
|
+
"pat.level.none": "Kein Zugriff",
|
|
14
|
+
"pat.level.read": "Lesen",
|
|
15
|
+
"pat.level.write": "Lesen & Schreiben",
|
|
16
|
+
"pat.create.expiry": "Gültig bis",
|
|
11
17
|
"pat.create.submit": "Token erstellen",
|
|
12
18
|
"pat.create.needName": "Bitte einen Namen vergeben.",
|
|
13
19
|
"pat.create.needScope": "Bitte mindestens eine Berechtigung wählen.",
|
|
14
|
-
"pat.
|
|
15
|
-
"pat.
|
|
16
|
-
"pat.
|
|
20
|
+
"pat.expiry.30d": "30 Tage",
|
|
21
|
+
"pat.expiry.90d": "90 Tage",
|
|
22
|
+
"pat.expiry.1y": "1 Jahr",
|
|
23
|
+
"pat.expiry.never": "Unbegrenzt",
|
|
24
|
+
"pat.created.hint":
|
|
25
|
+
"Kopiere diesen Token jetzt — er wird aus Sicherheitsgründen nur dieses eine Mal angezeigt.",
|
|
26
|
+
"pat.created.copy": "Kopieren",
|
|
27
|
+
"pat.created.copied": "Kopiert ✓",
|
|
28
|
+
"pat.created.dismiss": "Fertig",
|
|
17
29
|
"pat.list.title": "Deine Tokens",
|
|
18
30
|
"pat.list.empty": "Noch keine Tokens.",
|
|
19
|
-
"pat.list.created": "Erstellt",
|
|
20
|
-
"pat.list.revoked": "Widerrufen",
|
|
21
|
-
"pat.list.expired": "Abgelaufen",
|
|
22
31
|
"pat.list.revoke": "Widerrufen",
|
|
32
|
+
"pat.list.revoked": "Widerrufen",
|
|
33
|
+
"pat.list.validUntil": "Gültig bis {date}",
|
|
34
|
+
"pat.list.neverExpires": "Unbegrenzt gültig",
|
|
35
|
+
"pat.list.created": "Erstellt {date}",
|
|
23
36
|
"pat.error.generic": "Aktion fehlgeschlagen.",
|
|
24
37
|
},
|
|
25
38
|
en: {
|
|
26
39
|
"pat.title": "Personal Access Tokens",
|
|
27
40
|
"pat.create.title": "Create a new token",
|
|
41
|
+
"pat.create.subtitle": "Pick permissions and an expiration. The token is shown only once.",
|
|
28
42
|
"pat.create.name": "Name",
|
|
29
43
|
"pat.create.namePlaceholder": "e.g. CLI automation",
|
|
30
|
-
"pat.create.scopes": "
|
|
44
|
+
"pat.create.scopes": "Per-API access",
|
|
45
|
+
"pat.level.none": "No access",
|
|
46
|
+
"pat.level.read": "Read",
|
|
47
|
+
"pat.level.write": "Read & write",
|
|
48
|
+
"pat.create.expiry": "Expiration",
|
|
31
49
|
"pat.create.submit": "Create token",
|
|
32
50
|
"pat.create.needName": "Please enter a name.",
|
|
33
51
|
"pat.create.needScope": "Please select at least one permission.",
|
|
34
|
-
"pat.
|
|
35
|
-
"pat.
|
|
36
|
-
"pat.
|
|
52
|
+
"pat.expiry.30d": "30 days",
|
|
53
|
+
"pat.expiry.90d": "90 days",
|
|
54
|
+
"pat.expiry.1y": "1 year",
|
|
55
|
+
"pat.expiry.never": "No expiration",
|
|
56
|
+
"pat.created.hint": "Copy this token now — for security it is shown only this once.",
|
|
57
|
+
"pat.created.copy": "Copy",
|
|
58
|
+
"pat.created.copied": "Copied ✓",
|
|
59
|
+
"pat.created.dismiss": "Done",
|
|
37
60
|
"pat.list.title": "Your tokens",
|
|
38
61
|
"pat.list.empty": "No tokens yet.",
|
|
39
|
-
"pat.list.created": "Created",
|
|
40
|
-
"pat.list.revoked": "Revoked",
|
|
41
|
-
"pat.list.expired": "Expired",
|
|
42
62
|
"pat.list.revoke": "Revoke",
|
|
63
|
+
"pat.list.revoked": "Revoked",
|
|
64
|
+
"pat.list.validUntil": "Valid until {date}",
|
|
65
|
+
"pat.list.neverExpires": "Never expires",
|
|
66
|
+
"pat.list.created": "Created {date}",
|
|
43
67
|
"pat.error.generic": "Action failed.",
|
|
44
68
|
},
|
|
45
69
|
};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// PatTokensScreen — logged-in self-service for Personal Access Tokens
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// PatTokensScreen — logged-in self-service for Personal Access Tokens. Two-axis
|
|
3
|
+
// scopes (like GitHub fine-grained PATs): per API domain pick a permission LEVEL
|
|
4
|
+
// (no access / read / read & write). Mint → copy the plaintext ONCE → it's never
|
|
5
|
+
// re-displayed. Layout follows the framework's polished-screen convention (Form
|
|
6
|
+
// primitive + Tailwind), cards match the mh style. The feature registers this
|
|
7
|
+
// dormant (r.screen); the app places it via r.nav.
|
|
6
8
|
|
|
7
9
|
import {
|
|
8
10
|
useDispatcher,
|
|
@@ -12,95 +14,160 @@ import {
|
|
|
12
14
|
} from "@cosmicdrift/kumiko-renderer";
|
|
13
15
|
import { type ReactNode, useState } from "react";
|
|
14
16
|
import { PatHandlers, PatQueries } from "../constants";
|
|
17
|
+
import { parseGrant } from "../scopes";
|
|
15
18
|
|
|
16
|
-
type
|
|
19
|
+
type ScopeDomain = { readonly name: string; readonly label: string; readonly canWrite: boolean };
|
|
17
20
|
type TokenRow = {
|
|
18
21
|
readonly id: string;
|
|
19
22
|
readonly name: string;
|
|
20
|
-
readonly prefix: string;
|
|
21
23
|
readonly scopes: readonly string[];
|
|
22
24
|
readonly createdAt: string | null;
|
|
23
25
|
readonly expiresAt: string | null;
|
|
24
26
|
readonly revokedAt: string | null;
|
|
25
27
|
};
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
type Level = "none" | "read" | "write";
|
|
30
|
+
type ExpiryKey = "30d" | "90d" | "1y" | "never";
|
|
31
|
+
const EXPIRY_DAYS: Record<ExpiryKey, number | undefined> = {
|
|
32
|
+
"30d": 30,
|
|
33
|
+
"90d": 90,
|
|
34
|
+
"1y": 365,
|
|
35
|
+
never: undefined,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Timestamps arrive as ISO strings over the JSON API — slice the date part
|
|
39
|
+
// without touching the banned Date API.
|
|
40
|
+
function isoDate(value: string | null): string | null {
|
|
41
|
+
return typeof value === "string" && value.length >= 10 ? value.slice(0, 10) : null;
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
export function PatTokensScreen(): ReactNode {
|
|
32
45
|
const t = useTranslation();
|
|
33
|
-
const {
|
|
46
|
+
const { Form, Field, Input, Button, Banner, Card, Heading } = usePrimitives();
|
|
34
47
|
const dispatcher = useDispatcher();
|
|
35
48
|
|
|
36
|
-
const scopesQuery = useQuery<readonly
|
|
49
|
+
const scopesQuery = useQuery<readonly ScopeDomain[]>(PatQueries.availableScopes, {});
|
|
37
50
|
const listQuery = useQuery<readonly TokenRow[]>(PatQueries.mine, {});
|
|
38
51
|
|
|
39
52
|
const [name, setName] = useState("");
|
|
40
|
-
const [
|
|
41
|
-
const [
|
|
53
|
+
const [levels, setLevels] = useState<Readonly<Record<string, Level>>>({});
|
|
54
|
+
const [expiry, setExpiry] = useState<ExpiryKey>("90d");
|
|
55
|
+
const [minted, setMinted] = useState<{ token: string } | null>(null);
|
|
56
|
+
const [copied, setCopied] = useState(false);
|
|
42
57
|
const [error, setError] = useState<string | null>(null);
|
|
43
58
|
const [busy, setBusy] = useState(false);
|
|
44
59
|
|
|
45
|
-
const
|
|
60
|
+
const domains = scopesQuery.data ?? [];
|
|
46
61
|
const tokens = listQuery.data ?? [];
|
|
62
|
+
const labelOf = (domain: string): string =>
|
|
63
|
+
domains.find((d) => d.name === domain)?.label ?? domain;
|
|
64
|
+
|
|
65
|
+
// Granted scopes = "<domain>:<level>" for every domain not set to "none".
|
|
66
|
+
const grants = (): string[] =>
|
|
67
|
+
Object.entries(levels)
|
|
68
|
+
.filter(([, l]) => l !== "none")
|
|
69
|
+
.map(([domain, l]) => `${domain}:${l}`);
|
|
47
70
|
|
|
48
|
-
const
|
|
49
|
-
|
|
71
|
+
const setLevel = (domain: string, level: Level): void =>
|
|
72
|
+
setLevels((cur) => ({ ...cur, [domain]: level }));
|
|
50
73
|
|
|
51
74
|
const create = async (): Promise<void> => {
|
|
52
75
|
if (name.trim() === "") return setError(t("pat.create.needName"));
|
|
53
|
-
|
|
76
|
+
const scopes = grants();
|
|
77
|
+
if (scopes.length === 0) return setError(t("pat.create.needScope"));
|
|
54
78
|
setBusy(true);
|
|
55
79
|
setError(null);
|
|
80
|
+
const days = EXPIRY_DAYS[expiry];
|
|
56
81
|
const res = await dispatcher.write(PatHandlers.create, {
|
|
57
82
|
name: name.trim(),
|
|
58
|
-
scopes
|
|
83
|
+
scopes,
|
|
84
|
+
...(days !== undefined ? { expiresInDays: days } : {}),
|
|
59
85
|
});
|
|
60
86
|
setBusy(false);
|
|
61
87
|
if (!res.isSuccess) return setError(t("pat.error.generic"));
|
|
62
|
-
|
|
63
|
-
|
|
88
|
+
setMinted({ token: (res.data as { token: string }).token });
|
|
89
|
+
setCopied(false);
|
|
64
90
|
setName("");
|
|
65
|
-
|
|
91
|
+
setLevels({});
|
|
92
|
+
setExpiry("90d");
|
|
66
93
|
void listQuery.refetch?.();
|
|
67
94
|
};
|
|
68
95
|
|
|
96
|
+
const copy = async (): Promise<void> => {
|
|
97
|
+
if (!minted) return;
|
|
98
|
+
try {
|
|
99
|
+
await navigator.clipboard.writeText(minted.token);
|
|
100
|
+
setCopied(true);
|
|
101
|
+
} catch {
|
|
102
|
+
// clipboard blocked (non-secure context) — the token stays selectable in place
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
69
106
|
const revoke = async (id: string): Promise<void> => {
|
|
70
107
|
const res = await dispatcher.write(PatHandlers.revoke, { id });
|
|
71
108
|
if (!res.isSuccess) return setError(t("pat.error.generic"));
|
|
72
109
|
void listQuery.refetch?.();
|
|
73
110
|
};
|
|
74
111
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
112
|
+
// "Pages (read & write) · Tags (read) · Valid until … · Created …"
|
|
113
|
+
const meta = (row: TokenRow): string => {
|
|
114
|
+
const scopeText = row.scopes
|
|
115
|
+
.map((g) => {
|
|
116
|
+
const p = parseGrant(g);
|
|
117
|
+
if (!p) return g;
|
|
118
|
+
const lvl = p.level === "write" ? t("pat.level.write") : t("pat.level.read");
|
|
119
|
+
return `${labelOf(p.domain)} (${lvl})`;
|
|
120
|
+
})
|
|
121
|
+
.join(" · ");
|
|
122
|
+
const d = isoDate(row.expiresAt);
|
|
123
|
+
const parts = [
|
|
124
|
+
scopeText,
|
|
125
|
+
d ? t("pat.list.validUntil", { date: d }) : t("pat.list.neverExpires"),
|
|
126
|
+
];
|
|
127
|
+
const created = isoDate(row.createdAt);
|
|
128
|
+
if (created) parts.push(t("pat.list.created", { date: created }));
|
|
129
|
+
return parts.join(" · ");
|
|
79
130
|
};
|
|
80
131
|
|
|
81
132
|
return (
|
|
82
|
-
<
|
|
133
|
+
<div className="flex max-w-3xl flex-col gap-6 p-6">
|
|
83
134
|
<Heading>{t("pat.title")}</Heading>
|
|
84
135
|
|
|
85
136
|
{minted && (
|
|
86
137
|
<Banner
|
|
87
138
|
variant="info"
|
|
88
139
|
actions={
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
140
|
+
<>
|
|
141
|
+
<Button variant="primary" onClick={copy}>
|
|
142
|
+
{copied ? t("pat.created.copied") : t("pat.created.copy")}
|
|
143
|
+
</Button>
|
|
144
|
+
<Button variant="secondary" onClick={() => setMinted(null)}>
|
|
145
|
+
{t("pat.created.dismiss")}
|
|
146
|
+
</Button>
|
|
147
|
+
</>
|
|
92
148
|
}
|
|
93
149
|
>
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
150
|
+
<div className="flex flex-col gap-2">
|
|
151
|
+
<span className="text-sm">{t("pat.created.hint")}</span>
|
|
152
|
+
<code className="block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
|
|
153
|
+
{minted.token}
|
|
154
|
+
</code>
|
|
155
|
+
</div>
|
|
97
156
|
</Banner>
|
|
98
157
|
)}
|
|
99
158
|
|
|
100
159
|
{error && <Banner variant="error">{error}</Banner>}
|
|
101
160
|
|
|
102
|
-
<
|
|
103
|
-
|
|
161
|
+
<Form
|
|
162
|
+
title={t("pat.create.title")}
|
|
163
|
+
subtitle={t("pat.create.subtitle")}
|
|
164
|
+
onSubmit={create}
|
|
165
|
+
actions={
|
|
166
|
+
<Button type="submit" variant="primary" onClick={create} loading={busy} disabled={busy}>
|
|
167
|
+
{t("pat.create.submit")}
|
|
168
|
+
</Button>
|
|
169
|
+
}
|
|
170
|
+
>
|
|
104
171
|
<Field id="pat-name" label={t("pat.create.name")} required>
|
|
105
172
|
<Input
|
|
106
173
|
kind="text"
|
|
@@ -112,39 +179,73 @@ export function PatTokensScreen(): ReactNode {
|
|
|
112
179
|
disabled={busy}
|
|
113
180
|
/>
|
|
114
181
|
</Field>
|
|
115
|
-
|
|
116
|
-
{
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
182
|
+
|
|
183
|
+
{domains.map((d) => (
|
|
184
|
+
<Field key={d.name} id={`pat-scope-${d.name}`} label={d.label}>
|
|
185
|
+
<Input
|
|
186
|
+
kind="select"
|
|
187
|
+
id={`pat-scope-${d.name}`}
|
|
188
|
+
name={d.name}
|
|
189
|
+
value={levels[d.name] ?? "none"}
|
|
190
|
+
onChange={(v) => setLevel(d.name, v as Level)}
|
|
191
|
+
options={[
|
|
192
|
+
{ value: "none", label: t("pat.level.none") },
|
|
193
|
+
{ value: "read", label: t("pat.level.read") },
|
|
194
|
+
...(d.canWrite ? [{ value: "write", label: t("pat.level.write") }] : []),
|
|
195
|
+
]}
|
|
196
|
+
disabled={busy}
|
|
197
|
+
/>
|
|
198
|
+
</Field>
|
|
125
199
|
))}
|
|
126
|
-
<Button type="submit" variant="primary" onClick={create} loading={busy} disabled={busy}>
|
|
127
|
-
{t("pat.create.submit")}
|
|
128
|
-
</Button>
|
|
129
|
-
</Section>
|
|
130
200
|
|
|
131
|
-
|
|
201
|
+
<Field id="pat-expiry" label={t("pat.create.expiry")}>
|
|
202
|
+
<Input
|
|
203
|
+
kind="select"
|
|
204
|
+
id="pat-expiry"
|
|
205
|
+
name="expiry"
|
|
206
|
+
value={expiry}
|
|
207
|
+
onChange={(v) => setExpiry(v as ExpiryKey)}
|
|
208
|
+
options={[
|
|
209
|
+
{ value: "30d", label: t("pat.expiry.30d") },
|
|
210
|
+
{ value: "90d", label: t("pat.expiry.90d") },
|
|
211
|
+
{ value: "1y", label: t("pat.expiry.1y") },
|
|
212
|
+
{ value: "never", label: t("pat.expiry.never") },
|
|
213
|
+
]}
|
|
214
|
+
disabled={busy}
|
|
215
|
+
/>
|
|
216
|
+
</Field>
|
|
217
|
+
</Form>
|
|
218
|
+
|
|
219
|
+
<div className="flex flex-col gap-3">
|
|
132
220
|
<Heading>{t("pat.list.title")}</Heading>
|
|
133
|
-
{tokens.length === 0 &&
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
221
|
+
{tokens.length === 0 && (
|
|
222
|
+
<span className="text-sm text-muted-foreground">{t("pat.list.empty")}</span>
|
|
223
|
+
)}
|
|
224
|
+
{tokens.map((row) => {
|
|
225
|
+
const revoked = row.revokedAt !== null;
|
|
226
|
+
return (
|
|
227
|
+
<Card
|
|
228
|
+
key={row.id}
|
|
229
|
+
options={{ padded: false }}
|
|
230
|
+
className={`p-4 ${revoked ? "opacity-60" : ""}`}
|
|
231
|
+
>
|
|
232
|
+
<div className="flex items-center justify-between gap-3">
|
|
233
|
+
<div className="flex min-w-0 flex-col gap-0.5">
|
|
234
|
+
<span className="truncate text-sm font-semibold">{row.name}</span>
|
|
235
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
236
|
+
{revoked ? t("pat.list.revoked") : meta(row)}
|
|
237
|
+
</span>
|
|
238
|
+
</div>
|
|
239
|
+
{!revoked && (
|
|
240
|
+
<Button variant="danger" onClick={() => revoke(row.id)}>
|
|
241
|
+
{t("pat.list.revoke")}
|
|
242
|
+
</Button>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
</Card>
|
|
246
|
+
);
|
|
247
|
+
})}
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
149
250
|
);
|
|
150
251
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { AppContext, SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { bindAutoRevokeFromFeature, createSessionsFeature } from "../feature";
|
|
4
|
+
|
|
5
|
+
// The postSave hook is registered unconditionally; the revoker arrives either
|
|
6
|
+
// as the explicit constructor option or late-bound by run{Prod,Dev}App via
|
|
7
|
+
// bindAutoRevokeOnPasswordChange. These tests pin the binding + precedence
|
|
8
|
+
// semantics at the hook level — the full DB sweep is covered by
|
|
9
|
+
// password-auto-revoke.integration.test.ts.
|
|
10
|
+
|
|
11
|
+
const passwordChange: SaveContext = {
|
|
12
|
+
kind: "save",
|
|
13
|
+
id: "user-1",
|
|
14
|
+
data: {},
|
|
15
|
+
changes: { passwordHash: "new-hash" },
|
|
16
|
+
previous: {},
|
|
17
|
+
isNew: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// @cast-boundary test fixture — the hook never touches AppContext
|
|
21
|
+
const appContext = {} as unknown as AppContext;
|
|
22
|
+
|
|
23
|
+
function userPostSaveHook(feature: ReturnType<typeof createSessionsFeature>) {
|
|
24
|
+
const hook = feature.entityHooks?.postSave?.["user"]?.[0];
|
|
25
|
+
if (!hook) throw new Error("sessions feature did not register the user postSave hook");
|
|
26
|
+
return (ctx: SaveContext) => hook.fn(ctx, appContext);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("sessions auto-revoke binding", () => {
|
|
30
|
+
test("late-bound revoker fires on password change", async () => {
|
|
31
|
+
const revoker = mock(async (_userId: string) => 1);
|
|
32
|
+
const feature = createSessionsFeature();
|
|
33
|
+
|
|
34
|
+
const bind = bindAutoRevokeFromFeature(feature);
|
|
35
|
+
expect(bind).toBeDefined();
|
|
36
|
+
bind?.(revoker);
|
|
37
|
+
|
|
38
|
+
await userPostSaveHook(feature)(passwordChange);
|
|
39
|
+
expect(revoker).toHaveBeenCalledTimes(1);
|
|
40
|
+
expect(revoker).toHaveBeenCalledWith("user-1");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("unbound hook is a silent no-op", async () => {
|
|
44
|
+
const feature = createSessionsFeature();
|
|
45
|
+
// must resolve without throwing — stateless-JWT deployments have no revoker
|
|
46
|
+
const result = await userPostSaveHook(feature)(passwordChange);
|
|
47
|
+
expect(result).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("skips isNew and non-passwordHash changes", async () => {
|
|
51
|
+
const revoker = mock(async (_userId: string) => 1);
|
|
52
|
+
const feature = createSessionsFeature();
|
|
53
|
+
bindAutoRevokeFromFeature(feature)?.(revoker);
|
|
54
|
+
|
|
55
|
+
await userPostSaveHook(feature)({ ...passwordChange, isNew: true });
|
|
56
|
+
await userPostSaveHook(feature)({ ...passwordChange, changes: { displayName: "x" } });
|
|
57
|
+
expect(revoker).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("explicit constructor option wins over the runtime binding", async () => {
|
|
61
|
+
const explicit = mock(async (_userId: string) => 1);
|
|
62
|
+
const lateBound = mock(async (_userId: string) => 1);
|
|
63
|
+
const feature = createSessionsFeature({ autoRevokeOnPasswordChange: explicit });
|
|
64
|
+
|
|
65
|
+
bindAutoRevokeFromFeature(feature)?.(lateBound);
|
|
66
|
+
await userPostSaveHook(feature)(passwordChange);
|
|
67
|
+
|
|
68
|
+
expect(explicit).toHaveBeenCalledWith("user-1");
|
|
69
|
+
expect(lateBound).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/sessions/feature.ts
CHANGED
|
@@ -10,19 +10,46 @@ import { userSessionEntity } from "./schema/user-session";
|
|
|
10
10
|
import type { SessionMassRevoker } from "./session-callbacks";
|
|
11
11
|
|
|
12
12
|
export type SessionsFeatureOptions = {
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// A successful update on the `user` entity that changes the `passwordHash`
|
|
14
|
+
// column triggers a mass-revoke of every live session for that user.
|
|
15
|
+
// Industry-standard "password-change signs you out everywhere" flow,
|
|
16
|
+
// including the session that did the change itself — the client has
|
|
17
17
|
// to re-login after a password change.
|
|
18
18
|
//
|
|
19
19
|
// Runs as an afterCommit postSave hook: the password-change commits first,
|
|
20
20
|
// then the sessions are revoked. Best-effort — if the mass-revoker throws,
|
|
21
21
|
// the password change is NOT rolled back (a password change with a stale
|
|
22
22
|
// session still wins over a user-visible error on the change itself).
|
|
23
|
+
//
|
|
24
|
+
// Default: run{Prod,Dev}App bind their own sessionMassRevoker via
|
|
25
|
+
// `bindAutoRevokeOnPasswordChange` (secure-by-default). Set this option
|
|
26
|
+
// only to supply a custom revoker — an explicit value wins over the
|
|
27
|
+
// runtime binding.
|
|
23
28
|
readonly autoRevokeOnPasswordChange?: SessionMassRevoker;
|
|
24
29
|
};
|
|
25
30
|
|
|
31
|
+
export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
|
|
32
|
+
|
|
33
|
+
// Reads the late-bind setter off a mounted sessions feature's exports.
|
|
34
|
+
// run{Prod,Dev}App call it once the DB connection is concrete — the feature
|
|
35
|
+
// itself is constructed in app run-config long before a db exists, so the
|
|
36
|
+
// revoker can't be a constructor argument.
|
|
37
|
+
export function bindAutoRevokeFromFeature(
|
|
38
|
+
feature: FeatureDefinition,
|
|
39
|
+
): BindAutoRevokeOnPasswordChange | undefined {
|
|
40
|
+
const exports = feature.exports;
|
|
41
|
+
if (exports && typeof exports === "object" && "bindAutoRevokeOnPasswordChange" in exports) {
|
|
42
|
+
const { bindAutoRevokeOnPasswordChange } = exports as {
|
|
43
|
+
bindAutoRevokeOnPasswordChange: unknown;
|
|
44
|
+
};
|
|
45
|
+
if (typeof bindAutoRevokeOnPasswordChange === "function") {
|
|
46
|
+
// @cast-boundary exports-walk — feature.exports is untyped by design
|
|
47
|
+
return bindAutoRevokeOnPasswordChange as BindAutoRevokeOnPasswordChange;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
// The sessions feature registers the read_user_sessions table (as an
|
|
27
54
|
// unmanaged direct-write store, NOT an r.entity — see below) and the three
|
|
28
55
|
// user-facing handlers (mine/revoke/revoke-all-others). It intentionally does NOT
|
|
@@ -93,20 +120,26 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
93
120
|
// "the handler didn't touch this column", which is exactly the signal
|
|
94
121
|
// we want to skip on. Works for both direct user:update calls and any
|
|
95
122
|
// other handler that happens to write the column.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
123
|
+
let autoRevoke = options?.autoRevokeOnPasswordChange;
|
|
124
|
+
r.entityHook("postSave", "user", async (ctx) => {
|
|
125
|
+
// skip: nothing bound — stateless-JWT deployments without a runtime
|
|
126
|
+
// that calls bindAutoRevokeOnPasswordChange keep the old behavior.
|
|
127
|
+
if (!autoRevoke) return;
|
|
128
|
+
// skip: brand-new user, no sessions can possibly exist yet. The
|
|
129
|
+
// initial passwordHash on a user:create would trip the second guard
|
|
130
|
+
// otherwise — every registration would do a mass-revoke roundtrip
|
|
131
|
+
// for a user who literally has no rows in user_sessions.
|
|
132
|
+
if (ctx.isNew) return;
|
|
133
|
+
// skip: handler didn't touch passwordHash, nothing to revoke
|
|
134
|
+
if (ctx.changes["passwordHash"] === undefined) return;
|
|
135
|
+
await autoRevoke(String(ctx.id));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const bindAutoRevokeOnPasswordChange: BindAutoRevokeOnPasswordChange = (revoker) => {
|
|
139
|
+
// explicit constructor option wins over the runtime binding
|
|
140
|
+
autoRevoke ??= revoker;
|
|
141
|
+
};
|
|
109
142
|
|
|
110
|
-
return { handlers, queries };
|
|
143
|
+
return { handlers, queries, bindAutoRevokeOnPasswordChange };
|
|
111
144
|
});
|
|
112
145
|
}
|
package/src/sessions/index.ts
CHANGED
|
@@ -6,8 +6,8 @@ export {
|
|
|
6
6
|
SessionHandlers,
|
|
7
7
|
SessionQueries,
|
|
8
8
|
} from "./constants";
|
|
9
|
-
export type { SessionsFeatureOptions } from "./feature";
|
|
10
|
-
export { createSessionsFeature } from "./feature";
|
|
9
|
+
export type { BindAutoRevokeOnPasswordChange, SessionsFeatureOptions } from "./feature";
|
|
10
|
+
export { bindAutoRevokeFromFeature, createSessionsFeature } from "./feature";
|
|
11
11
|
export { userSessionEntity, userSessionTable } from "./schema/user-session";
|
|
12
12
|
export type {
|
|
13
13
|
SessionCallbacks,
|
|
@@ -109,6 +109,32 @@ describe("scenario 2: field-level read access", () => {
|
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
// --- find-for-auth is system-only: it returns the raw row incl. passwordHash,
|
|
113
|
+
// and every query handler is reachable via POST /api/query — so even a
|
|
114
|
+
// SystemAdmin (assignable app role) must be denied over HTTP. ---
|
|
115
|
+
|
|
116
|
+
describe("scenario 2b: find-for-auth is system-only", () => {
|
|
117
|
+
test("SystemAdmin over HTTP is denied — passwordHash must not leak", async () => {
|
|
118
|
+
await seedUser({
|
|
119
|
+
email: "auth-lookup@example.com",
|
|
120
|
+
displayName: "AuthLookup",
|
|
121
|
+
passwordHash: "must-never-leave-the-server",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const res = await stack.http.queryWithHeaders(
|
|
125
|
+
UserQueries.findForAuth,
|
|
126
|
+
{ email: "auth-lookup@example.com" },
|
|
127
|
+
systemAdmin,
|
|
128
|
+
{},
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
132
|
+
const body = await res.text();
|
|
133
|
+
expect(body).toContain("access_denied");
|
|
134
|
+
expect(body).not.toContain("must-never-leave-the-server");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
112
138
|
// --- Scenario 3: user edits own profile, email/passwordHash are system-locked ---
|
|
113
139
|
|
|
114
140
|
describe("scenario 3: self-update + field-level write access", () => {
|
|
@@ -7,10 +7,12 @@ import { userTable } from "../schema/user";
|
|
|
7
7
|
// by email OR id (exactly one, enforced by the schema). Used by the auth
|
|
8
8
|
// features via ctx.queryAs(systemUser, ...).
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// Access is system-only, NOT access.privileged: every query handler is
|
|
11
|
+
// reachable via POST /api/query, and "SystemAdmin" is an assignable app
|
|
12
|
+
// role — privileged would hand any SystemAdmin the raw passwordHash over
|
|
13
|
+
// HTTP. The "system" role can't be minted into a session (only
|
|
14
|
+
// createSystemUser() carries it), so system-only keeps this handler
|
|
15
|
+
// internal to ctx.queryAs(systemUser, ...) callers.
|
|
14
16
|
export const findForAuthQuery = defineQueryHandler({
|
|
15
17
|
name: "user:find-for-auth",
|
|
16
18
|
schema: z
|
|
@@ -24,7 +26,7 @@ export const findForAuthQuery = defineQueryHandler({
|
|
|
24
26
|
(v) => (v.email !== undefined) !== (v.id !== undefined),
|
|
25
27
|
{ message: "exactly one of email or id must be set" },
|
|
26
28
|
),
|
|
27
|
-
access: { roles: access.
|
|
29
|
+
access: { roles: access.system },
|
|
28
30
|
handler: async (query, ctx) => {
|
|
29
31
|
const where =
|
|
30
32
|
query.payload.email !== undefined
|