@cosmicdrift/kumiko-bundled-features 0.109.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.109.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.109.0",
99
- "@cosmicdrift/kumiko-framework": "0.109.0",
100
- "@cosmicdrift/kumiko-headless": "0.109.0",
101
- "@cosmicdrift/kumiko-renderer": "0.109.0",
102
- "@cosmicdrift/kumiko-renderer-web": "0.109.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
- if (opts.passwordReset && !opts.passwordReset.hmacSecret) {
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
- "[auth-email-password] passwordReset.hmacSecret must be non-empty when passwordReset is configured",
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 && !opts.emailVerification.hmacSecret) {
134
+ if (opts.emailVerification && opts.emailVerification.hmacSecret.length < MIN_HMAC_SECRET_LENGTH) {
131
135
  throw new Error(
132
- "[auth-email-password] emailVerification.hmacSecret must be non-empty when emailVerification is configured",
136
+ `[auth-email-password] emailVerification.hmacSecret must be ≥${MIN_HMAC_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
133
137
  );
134
138
  }
135
139
 
@@ -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
+ });
@@ -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
- // When wired, a successful update on the `user` entity that changes the
14
- // `passwordHash` column triggers a mass-revoke of every live session for
15
- // that user. Industry-standard "password-change signs you out everywhere"
16
- // flow, including the session that did the change itself — the client has
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
- const autoRevoke = options?.autoRevokeOnPasswordChange;
97
- if (autoRevoke) {
98
- r.entityHook("postSave", "user", async (ctx) => {
99
- // skip: brand-new user, no sessions can possibly exist yet. The
100
- // initial passwordHash on a user:create would trip the second guard
101
- // otherwise every registration would do a mass-revoke roundtrip
102
- // for a user who literally has no rows in user_sessions.
103
- if (ctx.isNew) return;
104
- // skip: handler didn't touch passwordHash, nothing to revoke
105
- if (ctx.changes["passwordHash"] === undefined) return;
106
- await autoRevoke(String(ctx.id));
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
  }
@@ -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
- // Field-level read rules allow passwordHash for the "privileged" role set,
11
- // so system callers see everything; any other caller is filtered even if
12
- // they somehow reach this handler. Access is also restricted to privileged
13
- // regular users or tenant admins cannot call this at all.
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.privileged },
29
+ access: { roles: access.system },
28
30
  handler: async (query, ctx) => {
29
31
  const where =
30
32
  query.payload.email !== undefined