@cosmicdrift/kumiko-framework 0.68.0 → 0.70.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-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.70.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ZodType, z } from "zod";
|
|
2
|
+
import type { ContainsSecret } from "../secrets/types";
|
|
2
3
|
import { runPipeline } from "./run-pipeline";
|
|
3
4
|
import type {
|
|
4
5
|
AccessRule,
|
|
@@ -83,6 +84,17 @@ export function defineWriteHandler<
|
|
|
83
84
|
TMap extends object = KumikoEventTypeMap,
|
|
84
85
|
>(
|
|
85
86
|
def: WriteHandlerInput<TName, TSchema, TData, TMap>,
|
|
87
|
+
// R6: a phantom rest-param. When the inferred response `TData` carries a
|
|
88
|
+
// Secret<> anywhere, ContainsSecret<TData> is `true` and this resolves to a
|
|
89
|
+
// 1-tuple the caller can't supply → compile error at the leak site. Clean
|
|
90
|
+
// responses get `[]`, so existing call-sites are unaffected. Checking it in a
|
|
91
|
+
// parameter post-inference (not as a `TData extends …` constraint, which TS
|
|
92
|
+
// rejects as circular, TS2313) is what makes inference survive.
|
|
93
|
+
..._noSecretInResponse: ContainsSecret<TData> extends true
|
|
94
|
+
? [
|
|
95
|
+
secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field.",
|
|
96
|
+
]
|
|
97
|
+
: []
|
|
86
98
|
): WriteHandlerDefinition<TName, TSchema, TData, TMap> {
|
|
87
99
|
// Runtime-guard against accidentally setting BOTH handler+perform.
|
|
88
100
|
// The discriminated-union type-error
|
|
@@ -160,6 +172,13 @@ export function defineQueryHandler<
|
|
|
160
172
|
TMap extends object = KumikoEventTypeMap,
|
|
161
173
|
>(
|
|
162
174
|
def: QueryHandlerDefinition<TName, TSchema, TResult, TMap>,
|
|
175
|
+
// R6: phantom rest-param — see defineWriteHandler. Forbids a Secret<> in the
|
|
176
|
+
// inferred query response `TResult` at compile time; `[]` for clean responses.
|
|
177
|
+
..._noSecretInResponse: ContainsSecret<TResult> extends true
|
|
178
|
+
? [
|
|
179
|
+
secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field.",
|
|
180
|
+
]
|
|
181
|
+
: []
|
|
163
182
|
): QueryHandlerDefinition<TName, TSchema, TResult, TMap> {
|
|
164
183
|
return def;
|
|
165
184
|
}
|
package/src/schema-cli.ts
CHANGED
|
@@ -296,7 +296,7 @@ export async function runSchemaCli(
|
|
|
296
296
|
return 0;
|
|
297
297
|
} catch (e) {
|
|
298
298
|
out.err("");
|
|
299
|
-
out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
299
|
+
out.err(` ✗ ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`);
|
|
300
300
|
out.err("");
|
|
301
301
|
return 1;
|
|
302
302
|
} finally {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { TenantId } from "../../engine";
|
|
4
|
+
import { defineQueryHandler } from "../../engine/define-handler";
|
|
5
|
+
import { type ContainsSecret, createSecret, type Secret } from "../index";
|
|
6
|
+
|
|
7
|
+
// R6 is a COMPILE-TIME guard. The type-level assertions below are the real
|
|
8
|
+
// coverage — they are checked by tsc (the bun runtime strips types without
|
|
9
|
+
// checking). The runtime test only proves the guarded registration functions
|
|
10
|
+
// still build a clean handler.
|
|
11
|
+
|
|
12
|
+
type Expect<T extends true> = T;
|
|
13
|
+
type Equal<A, B> =
|
|
14
|
+
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
15
|
+
|
|
16
|
+
// One exported tuple so each case is checked by tsc without tripping
|
|
17
|
+
// noUnusedLocals on a named alias. Any wrong predicate fails to compile here.
|
|
18
|
+
export type _R6TypeAssertions = [
|
|
19
|
+
// Clean responses → false, incl. branded primitives (TenantId, via Primitive)
|
|
20
|
+
// and opaque leaves (Temporal.Instant, Date, via SafeLeaf). These double as
|
|
21
|
+
// the false-positive guard: a mangled leaf would flip the case to `true`.
|
|
22
|
+
Expect<Equal<ContainsSecret<{ ok: true; id: string }>, false>>,
|
|
23
|
+
Expect<Equal<ContainsSecret<{ id: TenantId; when: Temporal.Instant; at: Date }>, false>>,
|
|
24
|
+
Expect<Equal<ContainsSecret<{ items: { label: string }[]; total: number }>, false>>,
|
|
25
|
+
Expect<Equal<ContainsSecret<{ apiKey: string }>, false>>, // revealed → plain string
|
|
26
|
+
// A Secret<> anywhere → true.
|
|
27
|
+
Expect<Equal<ContainsSecret<{ apiKey: Secret<string> }>, true>>,
|
|
28
|
+
Expect<Equal<ContainsSecret<{ creds: { token: Secret<string> } }>, true>>,
|
|
29
|
+
Expect<Equal<ContainsSecret<{ keys: Secret<string>[] }>, true>>,
|
|
30
|
+
Expect<Equal<ContainsSecret<{ when: Temporal.Instant; secret: Secret<string> }>, true>>,
|
|
31
|
+
// Uninspectable types are biased to `false` (allowed) — the runtime guard is
|
|
32
|
+
// the backstop. This is what keeps generic-over-response handlers compiling.
|
|
33
|
+
Expect<Equal<ContainsSecret<unknown>, false>>,
|
|
34
|
+
Expect<Equal<ContainsSecret<never>, false>>,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const schema = z.object({ q: z.string() });
|
|
38
|
+
declare const aSecret: Secret<string>;
|
|
39
|
+
|
|
40
|
+
// End-to-end: a Secret<> in the query response is a compile error at the call.
|
|
41
|
+
// @ts-expect-error — R6: Secret<> must not appear in a handler response
|
|
42
|
+
defineQueryHandler({
|
|
43
|
+
name: "t:query:leak",
|
|
44
|
+
schema,
|
|
45
|
+
handler: async () => ({ apiKey: aSecret }),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Regression: a handler generic over its response (the createTokenRequestHandler
|
|
49
|
+
// pattern) must NOT false-flag — the guard allows what it cannot prove. This
|
|
50
|
+
// call compiling at all is the assertion.
|
|
51
|
+
function genericResponseHandler<K extends string>(kind: K) {
|
|
52
|
+
return defineQueryHandler({
|
|
53
|
+
name: "t:query:generic",
|
|
54
|
+
schema,
|
|
55
|
+
handler: async () => ({ kind, ok: true }) as { kind: K; ok: boolean },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
void genericResponseHandler;
|
|
59
|
+
|
|
60
|
+
describe("R6 ContainsSecret", () => {
|
|
61
|
+
test("a clean query handler still builds — the guard param is invisible", () => {
|
|
62
|
+
const def = defineQueryHandler({
|
|
63
|
+
name: "t:query:clean",
|
|
64
|
+
schema,
|
|
65
|
+
handler: async () => ({ ok: true, value: createSecret("x").reveal() }),
|
|
66
|
+
});
|
|
67
|
+
expect(def.name).toBe("t:query:clean");
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/secrets/index.ts
CHANGED
package/src/secrets/types.ts
CHANGED
|
@@ -34,6 +34,58 @@ export function isSecret(v: unknown): v is Secret<unknown> {
|
|
|
34
34
|
return typeof v === "object" && v !== null && SecretBrand in v;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// --- Compile-time response guard (R6) --------------------------------------
|
|
38
|
+
//
|
|
39
|
+
// ContainsSecret<T> is `true` only when a Secret<> is DEFINITELY present
|
|
40
|
+
// somewhere in T. The handler-registration guard (defineWriteHandler/
|
|
41
|
+
// defineQueryHandler) turns a `true` into a compile error — the static twin of
|
|
42
|
+
// assertNoSecretLeak's runtime walk.
|
|
43
|
+
//
|
|
44
|
+
// Biased to `false`: anything it cannot inspect — a bare generic type param (a
|
|
45
|
+
// handler generic over its response), `unknown`/`any`, `never` — resolves to
|
|
46
|
+
// `false` = allowed, with the runtime guard as the backstop. The alternative
|
|
47
|
+
// (default-to-leak) false-flags every legitimate generic-over-response handler.
|
|
48
|
+
//
|
|
49
|
+
// Branch order is load-bearing: never/unknown/any first (uninspectable), then
|
|
50
|
+
// Secret, then primitives (covers branded primitives like TenantId without
|
|
51
|
+
// enumerating them), then the SafeLeaf allowlist (opaque class instances that
|
|
52
|
+
// blind `{ [K in keyof T] }` recursion would mangle — the type-level mirror of
|
|
53
|
+
// leak-guard.ts skipping non-plain objects), then arrays, then a "does any
|
|
54
|
+
// field contain a secret" fold over plain objects.
|
|
55
|
+
type Primitive = string | number | boolean | bigint | symbol | null | undefined;
|
|
56
|
+
|
|
57
|
+
// Opaque built-in leaves a response legitimately carries; never recurse into
|
|
58
|
+
// them. Extend when the bundled-features tsc sweep surfaces a real leaf type.
|
|
59
|
+
type SafeLeaf =
|
|
60
|
+
| Date
|
|
61
|
+
| RegExp
|
|
62
|
+
| Temporal.Instant
|
|
63
|
+
| Temporal.ZonedDateTime
|
|
64
|
+
| Temporal.PlainDate
|
|
65
|
+
| Temporal.PlainDateTime
|
|
66
|
+
| Temporal.PlainTime
|
|
67
|
+
| Temporal.PlainYearMonth
|
|
68
|
+
| Temporal.PlainMonthDay
|
|
69
|
+
| Temporal.Duration;
|
|
70
|
+
|
|
71
|
+
export type ContainsSecret<T> = [T] extends [never]
|
|
72
|
+
? false
|
|
73
|
+
: unknown extends T
|
|
74
|
+
? false
|
|
75
|
+
: T extends Secret<unknown>
|
|
76
|
+
? true
|
|
77
|
+
: T extends Primitive
|
|
78
|
+
? false
|
|
79
|
+
: T extends SafeLeaf
|
|
80
|
+
? false
|
|
81
|
+
: T extends readonly (infer U)[]
|
|
82
|
+
? ContainsSecret<U>
|
|
83
|
+
: T extends object
|
|
84
|
+
? true extends { [K in keyof T]-?: ContainsSecret<T[K]> }[keyof T]
|
|
85
|
+
? true
|
|
86
|
+
: false
|
|
87
|
+
: false;
|
|
88
|
+
|
|
37
89
|
// Per-read audit context. Populated by requireSecretsContext() wrapper so
|
|
38
90
|
// handlers don't need to pass userId/handlerName manually on every call.
|
|
39
91
|
// Undefined for framework-internal reads (rotation job, tests) — the audit
|