@ultimat3/core 22.5.0 → 22.6.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/CLAUDE.md +5 -2
- package/README.md +1 -0
- package/package.json +2 -2
- package/src/canonical-json.ts +4 -0
- package/src/client-paths.ts +35 -5
- package/src/cursor.ts +8 -0
- package/src/index.ts +9 -1
- package/src/keyed-fingerprint.ts +69 -0
- package/src/telemetry.ts +15 -2
package/CLAUDE.md
CHANGED
|
@@ -95,9 +95,12 @@ top-level `UltimateError` use in `error-codes.ts`.
|
|
|
95
95
|
derives their retry classification from the same set. It is a `SIDE_EFFECTS_ANCHORS` entry.
|
|
96
96
|
- `timing-safe-equal.ts` is the one constant-time comparison (`@ultimat3/auth`, `@ultimat3/storage`).
|
|
97
97
|
- **`canonical-json.ts`: `canonicalJson` is INJECTIVE and `fingerprint` is SHA-256/16 of it** — the
|
|
98
|
-
hash every sharing key is taken over (`
|
|
99
|
-
`
|
|
98
|
+
hash every in-memory sharing key is taken over (`query`'s `queryHash`, `realtime`'s `qid`).
|
|
99
|
+
`NaN`, `±Infinity` and `-0` are bare tokens; `Date`, `Map` and `Set` are TAGGED. Never a
|
|
100
100
|
fourth copy, never parseable (`@ultimat3/action`'s `stableStringify` is the document form).
|
|
101
|
+
- **A PERSISTED fingerprint is `keyedFingerprint`** (`keyed-fingerprint.ts`: HMAC under a per-purpose
|
|
102
|
+
key derived from the cursor secret, `h1:` versioned) — `action`'s idempotency `requestHash`. An
|
|
103
|
+
unkeyed prefix in a table is an offline oracle for a short account number in the input.
|
|
101
104
|
- **`decimal-order.ts`'s `compareDecimalText` answers `undefined` for a non-decimal**, and only a
|
|
102
105
|
caller that knows the column's kind may ask (`@ultimat3/entity`'s `compareByKind`) — never
|
|
103
106
|
`@ultimat3/query`, whose `OrderKey` has no kind.
|
package/README.md
CHANGED
|
@@ -498,6 +498,7 @@ never a silently wrong page.
|
|
|
498
498
|
|---|---|
|
|
499
499
|
| Signature | truncated HMAC-SHA256, compared in constant time |
|
|
500
500
|
| Secret | `configureCursorSigning()` at boot, else `ULTIMATE_CURSOR_SECRET`. **Read when a cursor is signed, never at import** — an app whose `openSecrets()` sets the variable during boot would otherwise sign every cursor with the dev key. Rotating it invalidates every open cursor |
|
|
501
|
+
| Also keys | `keyedFingerprint(value, purpose)` — `h1:<key id>:<HMAC>` over `canonicalJson`, under a per-purpose key derived from this secret; the fingerprint to PERSIST (`@ultimat3/action`'s idempotency `requestHash`). `compareFingerprint` answers `match` / `mismatch` / `unverifiable` (other key), and still checks a legacy bare `fingerprint()` exactly. Rotating the secret makes in-window stored fingerprints `unverifiable` |
|
|
501
502
|
| Signed, not encrypted | the client already has these rows; what it must not do is *invent* a position |
|
|
502
503
|
| `usesDevCursorSecret()` | true while the shipped dev key is in use |
|
|
503
504
|
| `resetCursorSigning()` | test seam: forget `configureCursorSigning` and fall back to the environment |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.6.0",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,6 @@
|
|
|
40
40
|
"test": "bun test"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ultimat3/schema": "22.
|
|
43
|
+
"@ultimat3/schema": "22.6.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/canonical-json.ts
CHANGED
|
@@ -119,6 +119,10 @@ export function canonicalJson(value: unknown): string {
|
|
|
119
119
|
* hands one caller another's rows. FNV-1a/32, which two of the three copies started as, is
|
|
120
120
|
* 4x10^9 values and brute-forceable offline in seconds: an input landing on another read's key was
|
|
121
121
|
* something an attacker could mint rather than something they had to wait for.
|
|
122
|
+
*
|
|
123
|
+
* UNKEYED, so never the thing to PERSIST beside input that may hold a low-entropy secret — a
|
|
124
|
+
* 64-bit prefix of a known hash is an offline oracle for a short account number. A stored
|
|
125
|
+
* fingerprint is `keyedFingerprint` (`keyed-fingerprint.ts`).
|
|
122
126
|
*/
|
|
123
127
|
export function fingerprint(value: unknown): string {
|
|
124
128
|
return new Bun.CryptoHasher('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
|
package/src/client-paths.ts
CHANGED
|
@@ -51,12 +51,42 @@ export function pluralize(word: string): string {
|
|
|
51
51
|
return `${word}s`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* How an action's export name becomes its URL. Declared once per app
|
|
56
|
+
* (`defineApi({ http: { pathStyle } })`), never per action — one app, one rule.
|
|
57
|
+
*
|
|
58
|
+
* | style | `publishPost` | `signIn` | `health` |
|
|
59
|
+
* |---|---|---|---|
|
|
60
|
+
* | `'resource'` (default) | `/api/posts/publish` | `/api/ins/sign` | `/api/healths/invoke` |
|
|
61
|
+
* | `'readable'` | `/api/publish-post` | `/api/sign-in` | `/api/health` |
|
|
62
|
+
*
|
|
63
|
+
* `'resource'` guesses a noun from the words after the first and pluralizes it, which is right for
|
|
64
|
+
* `verbNoun` names and ungrammatical for everything else. `'readable'` guesses nothing: the path IS
|
|
65
|
+
* the name, kebab-cased — the rule `/_x/query/<kebab>` has always followed for reads.
|
|
66
|
+
*/
|
|
67
|
+
export type ActionPathStyle = 'resource' | 'readable';
|
|
68
|
+
|
|
69
|
+
export const ACTION_PATH_STYLES: readonly ActionPathStyle[] = ['resource', 'readable'];
|
|
70
|
+
|
|
71
|
+
/** Every action is served under one prefix, whichever style derives the rest. */
|
|
72
|
+
export const ACTION_PATH_PREFIX = '/api';
|
|
73
|
+
|
|
54
74
|
/**
|
|
55
75
|
* `publishPost` -> `/api/posts/publish`, `updateUserProfile` -> `/api/user-profiles/update`,
|
|
56
|
-
* `checkout` -> `/api/checkouts/invoke` (single-word fallback).
|
|
76
|
+
* `checkout` -> `/api/checkouts/invoke` (single-word fallback) — the `'resource'` style.
|
|
77
|
+
* `'readable'` -> `/api/publish-post`, `/api/update-user-profile`, `/api/checkout`: `verb` is the
|
|
78
|
+
* first word and `resource` the rest, both unpluralized, and neither is part of the path.
|
|
57
79
|
*/
|
|
58
|
-
export function actionRoute(name: string): ActionRoute {
|
|
80
|
+
export function actionRoute(name: string, style: ActionPathStyle = 'resource'): ActionRoute {
|
|
59
81
|
const words = splitWords(name);
|
|
82
|
+
if (style === 'readable') {
|
|
83
|
+
const kebab = words.length === 0 ? 'invoke' : words.join('-');
|
|
84
|
+
return {
|
|
85
|
+
verb: words[0] ?? 'invoke',
|
|
86
|
+
resource: words.length < 2 ? kebab : words.slice(1).join('-'),
|
|
87
|
+
path: `${ACTION_PATH_PREFIX}/${kebab}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
60
90
|
const head = words[0] ?? 'invoke';
|
|
61
91
|
if (words.length < 2) {
|
|
62
92
|
const resource = pluralize(head);
|
|
@@ -68,9 +98,9 @@ export function actionRoute(name: string): ActionRoute {
|
|
|
68
98
|
return { verb: head, resource, path: `/api/${resource}/${head}` };
|
|
69
99
|
}
|
|
70
100
|
|
|
71
|
-
/** The path an action is POSTed to — `actionRoute(name).path`. */
|
|
72
|
-
export function actionPath(name: string): string {
|
|
73
|
-
return actionRoute(name).path;
|
|
101
|
+
/** The path an action is POSTed to — `actionRoute(name, style).path`. */
|
|
102
|
+
export function actionPath(name: string, style: ActionPathStyle = 'resource'): string {
|
|
103
|
+
return actionRoute(name, style).path;
|
|
74
104
|
}
|
|
75
105
|
|
|
76
106
|
/** `liveFeed` -> `/_x/query/live-feed`, read with `GET …?orgId=…`. */
|
package/src/cursor.ts
CHANGED
|
@@ -52,6 +52,14 @@ function currentSecret(): string {
|
|
|
52
52
|
return configured ?? Bun.env['ULTIMATE_CURSOR_SECRET'] ?? DEV_SECRET;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The app's signing secret, exactly as cursor signing reads it. Internal to `@ultimat3/core`:
|
|
57
|
+
* `keyed-fingerprint.ts` derives its own key from it, so an app has one secret to set, not two.
|
|
58
|
+
*/
|
|
59
|
+
export function currentSigningSecret(): string {
|
|
60
|
+
return currentSecret();
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
/** Set once at boot from the app secret. Rotating it invalidates every open cursor. */
|
|
56
64
|
export function configureCursorSigning(next: string): void {
|
|
57
65
|
configured = next;
|
package/src/index.ts
CHANGED
|
@@ -68,8 +68,10 @@ export type {
|
|
|
68
68
|
FlightPlan,
|
|
69
69
|
} from './client-flight';
|
|
70
70
|
export { createClientFlight, DEFAULT_CLIENT_RETRY, isTransientFailure } from './client-flight';
|
|
71
|
-
export type { ActionRoute } from './client-paths';
|
|
71
|
+
export type { ActionPathStyle, ActionRoute } from './client-paths';
|
|
72
72
|
export {
|
|
73
|
+
ACTION_PATH_PREFIX,
|
|
74
|
+
ACTION_PATH_STYLES,
|
|
73
75
|
actionPath,
|
|
74
76
|
actionRoute,
|
|
75
77
|
pluralize,
|
|
@@ -509,6 +511,12 @@ export {
|
|
|
509
511
|
} from './intl-cache';
|
|
510
512
|
export { isIsoDateTime } from './iso-date';
|
|
511
513
|
export { isJsonObject } from './json-object';
|
|
514
|
+
export type { FingerprintMatch } from './keyed-fingerprint';
|
|
515
|
+
export {
|
|
516
|
+
compareFingerprint,
|
|
517
|
+
KEYED_FINGERPRINT_VERSION,
|
|
518
|
+
keyedFingerprint,
|
|
519
|
+
} from './keyed-fingerprint';
|
|
512
520
|
export type {
|
|
513
521
|
HealthPayload,
|
|
514
522
|
HealthReport,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Single responsibility: a fingerprint of caller input that is safe to PERSIST. `fingerprint()` is
|
|
2
|
+
// an unkeyed SHA-256 prefix — fine as an in-memory sharing key, but stored beside a row it is an
|
|
3
|
+
// offline oracle: a 6–20-digit account number or an ID number in an input, with the other fields
|
|
4
|
+
// known, is brute-forced from a database read. Keyed with HMAC-SHA-256, the stored value proves
|
|
5
|
+
// nothing to anyone without the app's signing secret.
|
|
6
|
+
|
|
7
|
+
import { canonicalJson, fingerprint } from './canonical-json';
|
|
8
|
+
import { currentSigningSecret } from './cursor';
|
|
9
|
+
import { timingSafeEqual } from './timing-safe-equal';
|
|
10
|
+
|
|
11
|
+
/** The format tag. A stored value without it predates keying (see `compareFingerprint`). */
|
|
12
|
+
export const KEYED_FINGERPRINT_VERSION = 'h1';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a stored fingerprint says about an input:
|
|
16
|
+
* - `match` / `mismatch` — computed under the key in force now (or the legacy unkeyed form);
|
|
17
|
+
* - `unverifiable` — keyed under a secret this process does not hold (a rotation), or a format this
|
|
18
|
+
* build does not know. The input can be neither confirmed nor refused.
|
|
19
|
+
*/
|
|
20
|
+
export type FingerprintMatch = 'match' | 'mismatch' | 'unverifiable';
|
|
21
|
+
|
|
22
|
+
const LEGACY = /^[0-9a-f]{16}$/;
|
|
23
|
+
|
|
24
|
+
/** One key per purpose, derived from the signing secret, so no two uses share a MAC key. */
|
|
25
|
+
function derivedKey(purpose: string): string {
|
|
26
|
+
return new Bun.CryptoHasher('sha256', currentSigningSecret())
|
|
27
|
+
.update(`ultimate:keyed-fingerprint:${purpose}`)
|
|
28
|
+
.digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Names the key a fingerprint was made under, so a rotation reads as `unverifiable` rather than as
|
|
33
|
+
* a mismatch. 32 bits of a hash of a 256-bit derived key: a label, useless for recovering it.
|
|
34
|
+
*/
|
|
35
|
+
function keyId(key: string): string {
|
|
36
|
+
return new Bun.CryptoHasher('sha256').update(key).digest('hex').slice(0, 8);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `h1:<key id>:<HMAC-SHA-256 over canonical JSON, 128 bits>`. `purpose` separates uses: the same
|
|
41
|
+
* input fingerprints differently for two purposes. Under the shipped development secret this is as
|
|
42
|
+
* public as `fingerprint()` — production boot refuses that secret (`X_CURSOR_SECRET_DEV`).
|
|
43
|
+
*/
|
|
44
|
+
export function keyedFingerprint(value: unknown, purpose: string): string {
|
|
45
|
+
const key = derivedKey(purpose);
|
|
46
|
+
const mac = new Bun.CryptoHasher('sha256', key)
|
|
47
|
+
.update(canonicalJson(value))
|
|
48
|
+
.digest('hex')
|
|
49
|
+
.slice(0, 32);
|
|
50
|
+
return `${KEYED_FINGERPRINT_VERSION}:${keyId(key)}:${mac}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compare a STORED fingerprint with an input. A legacy unkeyed value (16 hex characters, written by
|
|
55
|
+
* a build before keying) is checked against `fingerprint()` computed here and never stored, so rows
|
|
56
|
+
* written across an upgrade keep their exact semantics until they age out.
|
|
57
|
+
*/
|
|
58
|
+
export function compareFingerprint(
|
|
59
|
+
stored: string,
|
|
60
|
+
value: unknown,
|
|
61
|
+
purpose: string,
|
|
62
|
+
): FingerprintMatch {
|
|
63
|
+
if (LEGACY.test(stored))
|
|
64
|
+
return timingSafeEqual(stored, fingerprint(value)) ? 'match' : 'mismatch';
|
|
65
|
+
const current = keyedFingerprint(value, purpose);
|
|
66
|
+
const prefix = current.slice(0, current.lastIndexOf(':') + 1);
|
|
67
|
+
if (!stored.startsWith(prefix)) return 'unverifiable';
|
|
68
|
+
return timingSafeEqual(stored, current) ? 'match' : 'mismatch';
|
|
69
|
+
}
|
package/src/telemetry.ts
CHANGED
|
@@ -69,6 +69,12 @@ export interface Span {
|
|
|
69
69
|
addEvent(name: string, attributes?: SpanAttributes): Span;
|
|
70
70
|
recordError(error: unknown): Span;
|
|
71
71
|
setStatus(code: SpanStatusCode, message?: string): Span;
|
|
72
|
+
/**
|
|
73
|
+
* Rename a span that learned what it is only after it started — an HTTP server span is opened
|
|
74
|
+
* before the router matches, and its name must be the route PATTERN (`GET /r/:token`), never the
|
|
75
|
+
* concrete URL, which carries capability tokens. A no-op once the span has ended.
|
|
76
|
+
*/
|
|
77
|
+
updateName(name: string): Span;
|
|
72
78
|
end(): void;
|
|
73
79
|
}
|
|
74
80
|
|
|
@@ -194,7 +200,8 @@ function inboundParent(parent: SpanContext | undefined): SpanContext | undefined
|
|
|
194
200
|
return parent === undefined || parent.spanId === '' ? undefined : parent;
|
|
195
201
|
}
|
|
196
202
|
|
|
197
|
-
export function startSpan(
|
|
203
|
+
export function startSpan(initialName: string, options?: StartSpanOptions): Span {
|
|
204
|
+
let name = initialName;
|
|
198
205
|
// A live span is what gives an outbound typed call a trace to continue; see the module.
|
|
199
206
|
installTraceHeaders();
|
|
200
207
|
const parent = options?.parent ?? currentSpanContext();
|
|
@@ -219,7 +226,9 @@ export function startSpan(name: string, options?: StartSpanOptions): Span {
|
|
|
219
226
|
let ended = false;
|
|
220
227
|
|
|
221
228
|
const span: Span = {
|
|
222
|
-
name
|
|
229
|
+
get name(): string {
|
|
230
|
+
return name;
|
|
231
|
+
},
|
|
223
232
|
context,
|
|
224
233
|
get ended(): boolean {
|
|
225
234
|
return ended;
|
|
@@ -260,6 +269,10 @@ export function startSpan(name: string, options?: StartSpanOptions): Span {
|
|
|
260
269
|
status = { code, message };
|
|
261
270
|
return span;
|
|
262
271
|
},
|
|
272
|
+
updateName(next) {
|
|
273
|
+
if (!ended) name = next;
|
|
274
|
+
return span;
|
|
275
|
+
},
|
|
263
276
|
end(): void {
|
|
264
277
|
if (ended) return;
|
|
265
278
|
ended = true;
|