@substrat-run/kernel 0.88.0 → 0.90.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/dist/denial-query.d.ts +3 -1
- package/dist/denial-query.d.ts.map +1 -1
- package/dist/denial-query.js +2 -1
- package/dist/denial-query.js.map +1 -1
- package/dist/idempotency.d.ts +143 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +197 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/impersonation.d.ts +102 -0
- package/dist/impersonation.d.ts.map +1 -0
- package/dist/impersonation.js +202 -0
- package/dist/impersonation.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/platform-request-query.d.ts +1 -1
- package/dist/platform-request-query.d.ts.map +1 -1
- package/dist/platform-request-query.js +1 -1
- package/dist/platform-request-query.js.map +1 -1
- package/dist/scope-host.d.ts +104 -5
- package/dist/scope-host.d.ts.map +1 -1
- package/dist/scope-host.js +7 -0
- package/dist/scope-host.js.map +1 -1
- package/dist/timeline.d.ts +97 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +112 -0
- package/dist/timeline.js.map +1 -0
- package/package.json +2 -2
package/dist/denial-query.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { type DenialBucket, type DenialFilter, type PermissionDenial } from '@su
|
|
|
12
12
|
* arrive from an HTTP query string. Values are bound, never interpolated.
|
|
13
13
|
*/
|
|
14
14
|
/** Every column of `_substrat_denials`, in the order `mapDenialRow` expects. */
|
|
15
|
-
export declare const DENIAL_COLUMNS = "id, actor, permission, tenant_id, scope_id, operation, at, drained_at";
|
|
15
|
+
export declare const DENIAL_COLUMNS = "id, actor, permission, tenant_id, scope_id, operation, impersonation, at, drained_at";
|
|
16
16
|
/** The raw row shape, as either adapter hands it back. */
|
|
17
17
|
export interface DenialRow {
|
|
18
18
|
id: string;
|
|
@@ -21,6 +21,8 @@ export interface DenialRow {
|
|
|
21
21
|
tenant_id: string;
|
|
22
22
|
scope_id: string | null;
|
|
23
23
|
operation: string | null;
|
|
24
|
+
/** K-42: the staff actor + session, as JSON, when the refusal was under one. */
|
|
25
|
+
impersonation: string | null;
|
|
24
26
|
at: string;
|
|
25
27
|
drained_at: string | null;
|
|
26
28
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"denial-query.d.ts","sourceRoot":"","sources":["../src/denial-query.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,YAAY,
|
|
1
|
+
{"version":3,"file":"denial-query.d.ts","sourceRoot":"","sources":["../src/denial-query.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,YAAY,EAEjB,KAAK,gBAAgB,EAItB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;GAWG;AAEH,gFAAgF;AAChF,eAAO,MAAM,cAAc,yFAC6D,CAAC;AAEzF,0DAA0D;AAC1D,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,gFAAgF;IAChF,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOjD;AAED,iDAAiD;AACjD,wBAAgB,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG,gBAAgB,CAa7D;AAgCD,uDAAuD;AACvD,wBAAgB,eAAe,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAA;CAAE,CAOnG;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG;IACzD,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;CAC7B,CAYA;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,YAAY,CASrE;AAED,8EAA8E;AAC9E,wBAAgB,iBAAiB,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG;IACxD,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;CAC7B,CAOA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,QAGL,CAAC;AAE5B,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB"}
|
package/dist/denial-query.js
CHANGED
|
@@ -12,7 +12,7 @@ import { DEFAULT_DENIAL_LIMIT, denialFilter, } from '@substrat-run/contracts';
|
|
|
12
12
|
* arrive from an HTTP query string. Values are bound, never interpolated.
|
|
13
13
|
*/
|
|
14
14
|
/** Every column of `_substrat_denials`, in the order `mapDenialRow` expects. */
|
|
15
|
-
export const DENIAL_COLUMNS = 'id, actor, permission, tenant_id, scope_id, operation, at, drained_at';
|
|
15
|
+
export const DENIAL_COLUMNS = 'id, actor, permission, tenant_id, scope_id, operation, impersonation, at, drained_at';
|
|
16
16
|
/**
|
|
17
17
|
* The stored spelling of an actor. The writer persists `JSON.stringify(actor)`, so a
|
|
18
18
|
* principal is stored WITH its quotes (`"01J…"`) while a system or connection actor is
|
|
@@ -39,6 +39,7 @@ export function mapDenialRow(row) {
|
|
|
39
39
|
tenantId: row.tenant_id,
|
|
40
40
|
scopeId: (row.scope_id ?? null),
|
|
41
41
|
operation: row.operation ?? null,
|
|
42
|
+
impersonation: row.impersonation == null ? null : JSON.parse(row.impersonation),
|
|
42
43
|
at: row.at,
|
|
43
44
|
drainedAt: row.drained_at ?? null,
|
|
44
45
|
};
|
package/dist/denial-query.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"denial-query.js","sourceRoot":"","sources":["../src/denial-query.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,oBAAoB,EACpB,YAAY,
|
|
1
|
+
{"version":3,"file":"denial-query.js","sourceRoot":"","sources":["../src/denial-query.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { type CheckSubject } from '@substrat-run/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* The spine half of request idempotency (#116) — what remembers a key, and what
|
|
4
|
+
* a second request carrying it is answered with.
|
|
5
|
+
*
|
|
6
|
+
* `@substrat-run/contracts` owns the wire (the header names, what makes a key
|
|
7
|
+
* well-formed, what makes two requests the same request); this owns the table
|
|
8
|
+
* and the decisions read off it. Both adapters call these rather than writing
|
|
9
|
+
* the SQL twice, for the reason `entityVersionQuery` gives one file over: two
|
|
10
|
+
* surfaces answering one question must not drift on what the answer means. Here
|
|
11
|
+
* the answer decides whether work happens at all.
|
|
12
|
+
*
|
|
13
|
+
* ## Recorded INSIDE the operation's transaction, which is the whole design
|
|
14
|
+
*
|
|
15
|
+
* The row is written in the same transaction as the work it describes, after the
|
|
16
|
+
* handler and before `COMMIT`. Three properties fall out of that one placement,
|
|
17
|
+
* and none of them needed a mechanism of its own:
|
|
18
|
+
*
|
|
19
|
+
* - **A failed request is retried, not replayed.** The operation threw, the
|
|
20
|
+
* transaction rolled back, and the row went with it. There is nothing to find,
|
|
21
|
+
* so the retry executes — which is correct, because nothing happened the first
|
|
22
|
+
* time. Recording failures would have meant deciding which of them are
|
|
23
|
+
* permanent, and that is a judgement no generic layer can make.
|
|
24
|
+
* - **A replayed response describes work that actually committed.** The row and
|
|
25
|
+
* the rows it is about are the same transaction; there is no window in which
|
|
26
|
+
* one exists without the other.
|
|
27
|
+
* - **The dedupe cannot be defeated by a concurrent retry.** Invokes serialise
|
|
28
|
+
* per scope in both adapters, so the second request takes its turn after the
|
|
29
|
+
* first has committed — no in-flight state, no "still processing" 409.
|
|
30
|
+
*
|
|
31
|
+
* ## What a replay is NOT
|
|
32
|
+
*
|
|
33
|
+
* It is not a fresh authorization. The recorded response is returned without
|
|
34
|
+
* running the handler, and the permission check lives inside the handler — so a
|
|
35
|
+
* caller whose access was revoked in the last 24 hours can still replay their own
|
|
36
|
+
* response. This is bounded by the two things that make it defensible: the row is
|
|
37
|
+
* keyed by the SUBJECT, so a caller can only ever reach responses they themselves
|
|
38
|
+
* received, and the window is a day. It is stated here rather than discovered,
|
|
39
|
+
* because the alternative — re-running the operation to re-check the permission —
|
|
40
|
+
* is the duplicate execution this feature exists to prevent.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* The dedupe table, kernel-owned so that no vertical carries a migration for it.
|
|
44
|
+
*
|
|
45
|
+
* Keyed `(subject, key)` rather than `(key)`, and that is a safety property, not
|
|
46
|
+
* a namespacing convenience: a key is a string a client chose, two clients will
|
|
47
|
+
* choose `1`, and a lookup that found the other one's row would replay a response
|
|
48
|
+
* across a principal boundary. With the subject in the primary key that is not a
|
|
49
|
+
* check that could be forgotten — it is a row that cannot be reached.
|
|
50
|
+
*
|
|
51
|
+
* `operation` is stored as well as hashed into `fingerprint`. The hash is what
|
|
52
|
+
* decides a mismatch; the column is what makes the table readable when someone is
|
|
53
|
+
* working out why a client is getting 409s.
|
|
54
|
+
*/
|
|
55
|
+
export declare const IDEMPOTENCY_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_idempotency (\n subject TEXT NOT NULL,\n key TEXT NOT NULL,\n operation TEXT NOT NULL,\n -- SHA-256 over (operation, parsed input). A second request under this key\n -- whose fingerprint differs is a REUSE, refused with 409 \u2014 never served the\n -- first request's response, which is a different request's answer.\n fingerprint TEXT NOT NULL,\n -- The operation's return value as JSON. NULL means one of two things, which\n -- `oversized` separates: the operation returned nothing, or the result was\n -- too large to record and a replay must be refused rather than re-executed.\n result TEXT,\n oversized INTEGER NOT NULL DEFAULT 0,\n -- #129's tag, replayed with the body so a retry hands the client the same\n -- ETag the original did. Without it a replayed response has no validator and\n -- the client's next conditional write has nothing to send.\n entity_version TEXT,\n recorded_at TEXT NOT NULL,\n PRIMARY KEY (subject, key)\n );\n CREATE INDEX IF NOT EXISTS _substrat_idempotency_recorded\n ON _substrat_idempotency (recorded_at);\n";
|
|
56
|
+
/** The stored row, as both adapters' SQL returns it. */
|
|
57
|
+
export interface IdempotencyRow {
|
|
58
|
+
readonly operation: string;
|
|
59
|
+
readonly fingerprint: string;
|
|
60
|
+
readonly result: string | null;
|
|
61
|
+
readonly oversized: number;
|
|
62
|
+
readonly entity_version: string | null;
|
|
63
|
+
}
|
|
64
|
+
/** What a replay answers with: the recorded return value and the recorded tag. */
|
|
65
|
+
export interface IdempotentReplay {
|
|
66
|
+
readonly result: unknown;
|
|
67
|
+
readonly entityVersion: string | null;
|
|
68
|
+
}
|
|
69
|
+
/** The subject a key is scoped to, in the form the column stores. */
|
|
70
|
+
export declare function idempotencySubject(subject: CheckSubject): string;
|
|
71
|
+
/**
|
|
72
|
+
* Refuse a malformed key at the door.
|
|
73
|
+
*
|
|
74
|
+
* `validation_failed` rather than `conflict`: nothing is in conflict, the caller
|
|
75
|
+
* sent a header we cannot store. Refused rather than ignored, for the reason the
|
|
76
|
+
* `If-Match` path gives — a caller who believes their retry is safe and whose
|
|
77
|
+
* key was silently dropped is in exactly the position this feature exists to
|
|
78
|
+
* prevent, arrived at through the feature itself.
|
|
79
|
+
*/
|
|
80
|
+
export declare function assertIdempotencyKey(key: string): void;
|
|
81
|
+
/** The lookup a retry is answered from. */
|
|
82
|
+
export declare function idempotencyLookupQuery(subject: CheckSubject, key: string): {
|
|
83
|
+
sql: string;
|
|
84
|
+
params: [string, string];
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Decide what a second request under this key gets.
|
|
88
|
+
*
|
|
89
|
+
* Two refusals and one replay, both refusals `conflict` (409) with a reason slug
|
|
90
|
+
* this feature owns:
|
|
91
|
+
*
|
|
92
|
+
* - **Reuse.** Same key, different request. The client's assertion that this is
|
|
93
|
+
* the request it sent before is false, and the one thing that must not happen
|
|
94
|
+
* is serving the earlier request's response to it.
|
|
95
|
+
* - **Unavailable.** The original response was too large to record. Refused
|
|
96
|
+
* rather than re-executed, which is the fail-closed direction: an error the
|
|
97
|
+
* caller can act on, instead of the duplicate work the key was sent to avoid.
|
|
98
|
+
*/
|
|
99
|
+
export declare function replayFor(key: string, fingerprint: string, row: IdempotencyRow): IdempotentReplay;
|
|
100
|
+
/**
|
|
101
|
+
* The row a completed operation leaves behind.
|
|
102
|
+
*
|
|
103
|
+
* Serialisation happens here rather than at each call site so the size decision
|
|
104
|
+
* has one home: over `IDEMPOTENCY_RESULT_LIMIT` the body is dropped and the key
|
|
105
|
+
* is recorded as oversized, which is what makes a later replay a refusal instead
|
|
106
|
+
* of a silent re-execution.
|
|
107
|
+
*
|
|
108
|
+
* `undefined` and `null` results are both stored as a NULL body with
|
|
109
|
+
* `oversized = 0`; a replay returns `undefined` for either. An operation whose
|
|
110
|
+
* return value a caller distinguishes on that difference has a bigger problem
|
|
111
|
+
* than this table.
|
|
112
|
+
*/
|
|
113
|
+
export declare function idempotencyRecordStatement(subject: CheckSubject, key: string, operation: string, fingerprint: string, result: unknown, entityVersion: string | null, at: string): {
|
|
114
|
+
sql: string;
|
|
115
|
+
params: [string, string, string, string, string | null, number, string | null, string];
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Age rows out, in the same transaction as the write that added one.
|
|
119
|
+
*
|
|
120
|
+
* Opportunistic rather than swept, deliberately. A sweeper would be a second
|
|
121
|
+
* schedule, a second failure mode and a second thing to deploy, for a table whose
|
|
122
|
+
* only writer is already here holding a transaction open. Bounded work: the
|
|
123
|
+
* `recorded_at` index makes it a range delete, and it runs only on an invocation
|
|
124
|
+
* that carried a key — so a fleet that never uses the feature never pays for it.
|
|
125
|
+
*
|
|
126
|
+
* The consequence worth stating: a scope that stops receiving keyed requests
|
|
127
|
+
* keeps its last rows past the window. They are inert (nothing reads a row
|
|
128
|
+
* without a key that matches it) and the next keyed request clears them.
|
|
129
|
+
*/
|
|
130
|
+
export declare function idempotencyPruneStatement(now: string): {
|
|
131
|
+
sql: string;
|
|
132
|
+
params: [string];
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* The refusal an operation that declared `idempotency: false` answers a key with.
|
|
136
|
+
*
|
|
137
|
+
* Not a `conflict` — nothing conflicts — and not silence, which is the failure
|
|
138
|
+
* mode every branch of this feature is written to avoid. The operation opted out
|
|
139
|
+
* because its response must not be recorded; a caller who sent a key and got a
|
|
140
|
+
* 200 would believe a retry is safe when the second one will execute again.
|
|
141
|
+
*/
|
|
142
|
+
export declare function idempotencyOptedOutMessage(operation: string): string;
|
|
143
|
+
//# sourceMappingURL=idempotency.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,YAAY,EAClB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe,+nCAuB3B,CAAC;AAEF,wDAAwD;AACxD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CACxC;AAED,kFAAkF;AAClF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AAED,qEAAqE;AACrE,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAEhE;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAMtD;AAED,2CAA2C;AAC3C,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,YAAY,EACrB,GAAG,EAAE,MAAM,GACV;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAO3C;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CACvB,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,cAAc,GAClB,gBAAgB,CAsBlB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,YAAY,EACrB,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,OAAO,EACf,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,EAAE,EAAE,MAAM,GACT;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC,CAAA;CAAE,CAmBzG;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,MAAM,CAAC,CAAA;CAAE,CAMxF;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAKpE"}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { IDEMPOTENCY_REPLAY_UNAVAILABLE, IDEMPOTENCY_RESULT_LIMIT, IDEMPOTENCY_RETENTION_MS, IDEMPOTENCY_REUSED, isValidIdempotencyKey, subjectRef, substratError, } from '@substrat-run/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* The spine half of request idempotency (#116) — what remembers a key, and what
|
|
4
|
+
* a second request carrying it is answered with.
|
|
5
|
+
*
|
|
6
|
+
* `@substrat-run/contracts` owns the wire (the header names, what makes a key
|
|
7
|
+
* well-formed, what makes two requests the same request); this owns the table
|
|
8
|
+
* and the decisions read off it. Both adapters call these rather than writing
|
|
9
|
+
* the SQL twice, for the reason `entityVersionQuery` gives one file over: two
|
|
10
|
+
* surfaces answering one question must not drift on what the answer means. Here
|
|
11
|
+
* the answer decides whether work happens at all.
|
|
12
|
+
*
|
|
13
|
+
* ## Recorded INSIDE the operation's transaction, which is the whole design
|
|
14
|
+
*
|
|
15
|
+
* The row is written in the same transaction as the work it describes, after the
|
|
16
|
+
* handler and before `COMMIT`. Three properties fall out of that one placement,
|
|
17
|
+
* and none of them needed a mechanism of its own:
|
|
18
|
+
*
|
|
19
|
+
* - **A failed request is retried, not replayed.** The operation threw, the
|
|
20
|
+
* transaction rolled back, and the row went with it. There is nothing to find,
|
|
21
|
+
* so the retry executes — which is correct, because nothing happened the first
|
|
22
|
+
* time. Recording failures would have meant deciding which of them are
|
|
23
|
+
* permanent, and that is a judgement no generic layer can make.
|
|
24
|
+
* - **A replayed response describes work that actually committed.** The row and
|
|
25
|
+
* the rows it is about are the same transaction; there is no window in which
|
|
26
|
+
* one exists without the other.
|
|
27
|
+
* - **The dedupe cannot be defeated by a concurrent retry.** Invokes serialise
|
|
28
|
+
* per scope in both adapters, so the second request takes its turn after the
|
|
29
|
+
* first has committed — no in-flight state, no "still processing" 409.
|
|
30
|
+
*
|
|
31
|
+
* ## What a replay is NOT
|
|
32
|
+
*
|
|
33
|
+
* It is not a fresh authorization. The recorded response is returned without
|
|
34
|
+
* running the handler, and the permission check lives inside the handler — so a
|
|
35
|
+
* caller whose access was revoked in the last 24 hours can still replay their own
|
|
36
|
+
* response. This is bounded by the two things that make it defensible: the row is
|
|
37
|
+
* keyed by the SUBJECT, so a caller can only ever reach responses they themselves
|
|
38
|
+
* received, and the window is a day. It is stated here rather than discovered,
|
|
39
|
+
* because the alternative — re-running the operation to re-check the permission —
|
|
40
|
+
* is the duplicate execution this feature exists to prevent.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* The dedupe table, kernel-owned so that no vertical carries a migration for it.
|
|
44
|
+
*
|
|
45
|
+
* Keyed `(subject, key)` rather than `(key)`, and that is a safety property, not
|
|
46
|
+
* a namespacing convenience: a key is a string a client chose, two clients will
|
|
47
|
+
* choose `1`, and a lookup that found the other one's row would replay a response
|
|
48
|
+
* across a principal boundary. With the subject in the primary key that is not a
|
|
49
|
+
* check that could be forgotten — it is a row that cannot be reached.
|
|
50
|
+
*
|
|
51
|
+
* `operation` is stored as well as hashed into `fingerprint`. The hash is what
|
|
52
|
+
* decides a mismatch; the column is what makes the table readable when someone is
|
|
53
|
+
* working out why a client is getting 409s.
|
|
54
|
+
*/
|
|
55
|
+
export const IDEMPOTENCY_DDL = `
|
|
56
|
+
CREATE TABLE IF NOT EXISTS _substrat_idempotency (
|
|
57
|
+
subject TEXT NOT NULL,
|
|
58
|
+
key TEXT NOT NULL,
|
|
59
|
+
operation TEXT NOT NULL,
|
|
60
|
+
-- SHA-256 over (operation, parsed input). A second request under this key
|
|
61
|
+
-- whose fingerprint differs is a REUSE, refused with 409 — never served the
|
|
62
|
+
-- first request's response, which is a different request's answer.
|
|
63
|
+
fingerprint TEXT NOT NULL,
|
|
64
|
+
-- The operation's return value as JSON. NULL means one of two things, which
|
|
65
|
+
-- \`oversized\` separates: the operation returned nothing, or the result was
|
|
66
|
+
-- too large to record and a replay must be refused rather than re-executed.
|
|
67
|
+
result TEXT,
|
|
68
|
+
oversized INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
-- #129's tag, replayed with the body so a retry hands the client the same
|
|
70
|
+
-- ETag the original did. Without it a replayed response has no validator and
|
|
71
|
+
-- the client's next conditional write has nothing to send.
|
|
72
|
+
entity_version TEXT,
|
|
73
|
+
recorded_at TEXT NOT NULL,
|
|
74
|
+
PRIMARY KEY (subject, key)
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS _substrat_idempotency_recorded
|
|
77
|
+
ON _substrat_idempotency (recorded_at);
|
|
78
|
+
`;
|
|
79
|
+
/** The subject a key is scoped to, in the form the column stores. */
|
|
80
|
+
export function idempotencySubject(subject) {
|
|
81
|
+
return subjectRef(subject);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Refuse a malformed key at the door.
|
|
85
|
+
*
|
|
86
|
+
* `validation_failed` rather than `conflict`: nothing is in conflict, the caller
|
|
87
|
+
* sent a header we cannot store. Refused rather than ignored, for the reason the
|
|
88
|
+
* `If-Match` path gives — a caller who believes their retry is safe and whose
|
|
89
|
+
* key was silently dropped is in exactly the position this feature exists to
|
|
90
|
+
* prevent, arrived at through the feature itself.
|
|
91
|
+
*/
|
|
92
|
+
export function assertIdempotencyKey(key) {
|
|
93
|
+
if (isValidIdempotencyKey(key))
|
|
94
|
+
return;
|
|
95
|
+
throw substratError('validation_failed', 'Idempotency-Key must be 1-255 visible ASCII characters with no spaces');
|
|
96
|
+
}
|
|
97
|
+
/** The lookup a retry is answered from. */
|
|
98
|
+
export function idempotencyLookupQuery(subject, key) {
|
|
99
|
+
return {
|
|
100
|
+
sql: 'SELECT operation, fingerprint, result, oversized, entity_version ' +
|
|
101
|
+
'FROM _substrat_idempotency WHERE subject = ? AND key = ?',
|
|
102
|
+
params: [idempotencySubject(subject), key],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Decide what a second request under this key gets.
|
|
107
|
+
*
|
|
108
|
+
* Two refusals and one replay, both refusals `conflict` (409) with a reason slug
|
|
109
|
+
* this feature owns:
|
|
110
|
+
*
|
|
111
|
+
* - **Reuse.** Same key, different request. The client's assertion that this is
|
|
112
|
+
* the request it sent before is false, and the one thing that must not happen
|
|
113
|
+
* is serving the earlier request's response to it.
|
|
114
|
+
* - **Unavailable.** The original response was too large to record. Refused
|
|
115
|
+
* rather than re-executed, which is the fail-closed direction: an error the
|
|
116
|
+
* caller can act on, instead of the duplicate work the key was sent to avoid.
|
|
117
|
+
*/
|
|
118
|
+
export function replayFor(key, fingerprint, row) {
|
|
119
|
+
if (row.fingerprint !== fingerprint) {
|
|
120
|
+
throw substratError('conflict', `Idempotency-Key '${key}' was already used for a different request ` +
|
|
121
|
+
`(${row.operation}). A key identifies one request; use a fresh one`, { reason: IDEMPOTENCY_REUSED });
|
|
122
|
+
}
|
|
123
|
+
if (row.oversized !== 0) {
|
|
124
|
+
throw substratError('conflict', `the original response for Idempotency-Key '${key}' was too large to record, ` +
|
|
125
|
+
'so this retry cannot be answered from it — the original request did complete, ' +
|
|
126
|
+
'and re-running it would duplicate the work the key exists to prevent', { reason: IDEMPOTENCY_REPLAY_UNAVAILABLE });
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
result: row.result === null ? undefined : JSON.parse(row.result),
|
|
130
|
+
entityVersion: row.entity_version,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The row a completed operation leaves behind.
|
|
135
|
+
*
|
|
136
|
+
* Serialisation happens here rather than at each call site so the size decision
|
|
137
|
+
* has one home: over `IDEMPOTENCY_RESULT_LIMIT` the body is dropped and the key
|
|
138
|
+
* is recorded as oversized, which is what makes a later replay a refusal instead
|
|
139
|
+
* of a silent re-execution.
|
|
140
|
+
*
|
|
141
|
+
* `undefined` and `null` results are both stored as a NULL body with
|
|
142
|
+
* `oversized = 0`; a replay returns `undefined` for either. An operation whose
|
|
143
|
+
* return value a caller distinguishes on that difference has a bigger problem
|
|
144
|
+
* than this table.
|
|
145
|
+
*/
|
|
146
|
+
export function idempotencyRecordStatement(subject, key, operation, fingerprint, result, entityVersion, at) {
|
|
147
|
+
const serialised = result === undefined ? null : JSON.stringify(result) ?? null;
|
|
148
|
+
const oversized = serialised !== null && serialised.length > IDEMPOTENCY_RESULT_LIMIT;
|
|
149
|
+
return {
|
|
150
|
+
sql: 'INSERT INTO _substrat_idempotency ' +
|
|
151
|
+
'(subject, key, operation, fingerprint, result, oversized, entity_version, recorded_at) ' +
|
|
152
|
+
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
153
|
+
params: [
|
|
154
|
+
idempotencySubject(subject),
|
|
155
|
+
key,
|
|
156
|
+
operation,
|
|
157
|
+
fingerprint,
|
|
158
|
+
oversized ? null : serialised,
|
|
159
|
+
oversized ? 1 : 0,
|
|
160
|
+
entityVersion,
|
|
161
|
+
at,
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Age rows out, in the same transaction as the write that added one.
|
|
167
|
+
*
|
|
168
|
+
* Opportunistic rather than swept, deliberately. A sweeper would be a second
|
|
169
|
+
* schedule, a second failure mode and a second thing to deploy, for a table whose
|
|
170
|
+
* only writer is already here holding a transaction open. Bounded work: the
|
|
171
|
+
* `recorded_at` index makes it a range delete, and it runs only on an invocation
|
|
172
|
+
* that carried a key — so a fleet that never uses the feature never pays for it.
|
|
173
|
+
*
|
|
174
|
+
* The consequence worth stating: a scope that stops receiving keyed requests
|
|
175
|
+
* keeps its last rows past the window. They are inert (nothing reads a row
|
|
176
|
+
* without a key that matches it) and the next keyed request clears them.
|
|
177
|
+
*/
|
|
178
|
+
export function idempotencyPruneStatement(now) {
|
|
179
|
+
const cutoff = new Date(Date.parse(now) - IDEMPOTENCY_RETENTION_MS).toISOString();
|
|
180
|
+
return {
|
|
181
|
+
sql: 'DELETE FROM _substrat_idempotency WHERE recorded_at < ?',
|
|
182
|
+
params: [cutoff],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The refusal an operation that declared `idempotency: false` answers a key with.
|
|
187
|
+
*
|
|
188
|
+
* Not a `conflict` — nothing conflicts — and not silence, which is the failure
|
|
189
|
+
* mode every branch of this feature is written to avoid. The operation opted out
|
|
190
|
+
* because its response must not be recorded; a caller who sent a key and got a
|
|
191
|
+
* 200 would believe a retry is safe when the second one will execute again.
|
|
192
|
+
*/
|
|
193
|
+
export function idempotencyOptedOutMessage(operation) {
|
|
194
|
+
return (`${operation} declares \`idempotency: false\` and cannot honour an Idempotency-Key — ` +
|
|
195
|
+
'its response is not recorded, so a retry would execute it a second time');
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=idempotency.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules an impersonated session is held to (K-42, #868) — kernel-side, so
|
|
3
|
+
* both adapters enforce the same ones.
|
|
4
|
+
*
|
|
5
|
+
* An adapter supplies storage and a clock. Everything that decides whether a
|
|
6
|
+
* session may be opened, whether it may still be used, and what it may do lives
|
|
7
|
+
* here: a rule implemented twice is a rule that will eventually be two rules,
|
|
8
|
+
* and this is not a surface where the two versions may drift.
|
|
9
|
+
*
|
|
10
|
+
* What is deliberately NOT here: the permission evaluation. An impersonated
|
|
11
|
+
* operation checks as the impersonated principal through the ordinary
|
|
12
|
+
* `PermissionChecker`, with no override branch anywhere — the same discipline
|
|
13
|
+
* that made `system:<moduleId>` a subject rather than a bypass (#383). A door
|
|
14
|
+
* that grants authority by opening is a door nobody can audit.
|
|
15
|
+
*/
|
|
16
|
+
import { SubstratError, type BeginImpersonationInput, type ImpersonationFilter, type ImpersonationSession, type ImpersonationSessionId, type ImpersonationStamp, type Instant, type PlatformActorId, type ScopeId, type TenantId } from '@substrat-run/contracts';
|
|
17
|
+
/**
|
|
18
|
+
* A session refused: expired, ended, pointed at the wrong scope, or asked to do
|
|
19
|
+
* something a `read-only` session may not.
|
|
20
|
+
*
|
|
21
|
+
* `forbidden` rather than `permission_denied`, and the distinction is worth the
|
|
22
|
+
* separate class: the principal being impersonated may well hold the permission,
|
|
23
|
+
* and the check may well have passed. What refused is the SESSION. Recording
|
|
24
|
+
* this in the denial log would put a row there about a permission nobody was
|
|
25
|
+
* actually denied.
|
|
26
|
+
*/
|
|
27
|
+
export declare class ImpersonationRefused extends SubstratError {
|
|
28
|
+
constructor(message: string);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Mint a session from a staff request. Pure: the caller supplies the id and the
|
|
32
|
+
* instant, so a frozen clock produces a deterministic session (#812) and the
|
|
33
|
+
* adapter keeps its one source of ULIDs.
|
|
34
|
+
*
|
|
35
|
+
* `minutes` is CAPPED BY THE SCHEMA rather than clamped here — a caller asking
|
|
36
|
+
* for a day is refused with a parse error naming the ceiling, because silently
|
|
37
|
+
* handing back a shorter session than was asked for is how somebody comes to
|
|
38
|
+
* believe they have one that is still open.
|
|
39
|
+
*/
|
|
40
|
+
export declare function newImpersonationSession(id: string, actor: PlatformActorId, input: BeginImpersonationInput, now: Instant): ImpersonationSession;
|
|
41
|
+
/**
|
|
42
|
+
* Is this session usable, right now, for this scope?
|
|
43
|
+
*
|
|
44
|
+
* Called at the DOOR and again on EVERY INVOKE. Twice deliberately: a stub is a
|
|
45
|
+
* capability and nothing forces a caller to drop it, so a session checked only
|
|
46
|
+
* when the stub was minted would be a session whose expiry meant nothing to the
|
|
47
|
+
* one caller holding it — the exact shape of a time-box that is not one.
|
|
48
|
+
*
|
|
49
|
+
* Expiry is compared as ISO text, which sorts lexicographically, so neither
|
|
50
|
+
* adapter parses a date to answer it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function assertSessionUsable(session: ImpersonationSession, now: Instant, at?: {
|
|
53
|
+
tenantId: TenantId;
|
|
54
|
+
scopeId: ScopeId;
|
|
55
|
+
}): void;
|
|
56
|
+
/**
|
|
57
|
+
* The effecting verbs a `read-only` session refuses, by name.
|
|
58
|
+
*
|
|
59
|
+
* Refusing at the verb is the half a person notices; the transaction being
|
|
60
|
+
* rolled back rather than committed is the half that is actually load-bearing,
|
|
61
|
+
* since `ctx.sql.exec` can write a row without going through any of these. Both
|
|
62
|
+
* are needed: without the rollback the guarantee is a promise about which
|
|
63
|
+
* functions module code happens to call, and without the refusal a support
|
|
64
|
+
* engineer watches a save appear to succeed and silently vanish.
|
|
65
|
+
*/
|
|
66
|
+
export declare function assertImpersonationWrites(session: ImpersonationSession | undefined, verb: string): void;
|
|
67
|
+
/** What the kernel stamps on a record raised under this session. */
|
|
68
|
+
export declare function impersonationStampOf(session: ImpersonationSession): ImpersonationStamp;
|
|
69
|
+
/** Every column of `_substrat_impersonations`, in the order `mapImpersonationRow` expects. */
|
|
70
|
+
export declare const IMPERSONATION_COLUMNS = "id, actor, principal, tenant_id, scope_id, reason, mode, started_at, expires_at, ended_at";
|
|
71
|
+
/** The raw row shape, as either adapter hands it back. */
|
|
72
|
+
export interface ImpersonationRow {
|
|
73
|
+
id: string;
|
|
74
|
+
actor: string;
|
|
75
|
+
principal: string;
|
|
76
|
+
tenant_id: string;
|
|
77
|
+
scope_id: string;
|
|
78
|
+
reason: string;
|
|
79
|
+
mode: string;
|
|
80
|
+
started_at: string;
|
|
81
|
+
expires_at: string;
|
|
82
|
+
ended_at: string | null;
|
|
83
|
+
}
|
|
84
|
+
export declare function mapImpersonationRow(row: ImpersonationRow): ImpersonationSession;
|
|
85
|
+
export declare function impersonationRowValues(session: ImpersonationSession): (string | null)[];
|
|
86
|
+
/** Read one session by id — the door's own lookup, shared so both adapters spell it once. */
|
|
87
|
+
export declare function impersonationByIdQuery(id: ImpersonationSessionId | string): {
|
|
88
|
+
sql: string;
|
|
89
|
+
params: string[];
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* A bounded page of sessions, newest first. `active` is resolved against the
|
|
93
|
+
* caller's `now` rather than a stored flag: a session becomes inactive by the
|
|
94
|
+
* clock moving, and a flag would need somebody to come and set it.
|
|
95
|
+
*/
|
|
96
|
+
export declare function impersonationListQuery(filter: ImpersonationFilter | undefined, now: Instant): {
|
|
97
|
+
sql: string;
|
|
98
|
+
params: (string | number)[];
|
|
99
|
+
};
|
|
100
|
+
/** The DDL both adapters create the session store from — one spelling, one shape. */
|
|
101
|
+
export declare const IMPERSONATION_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_impersonations (\n id TEXT PRIMARY KEY,\n -- The REAL actor. Never a principal: a platform actor is branded apart from\n -- one precisely so a staff member can never read as a person in a trail.\n actor TEXT NOT NULL,\n principal TEXT NOT NULL,\n tenant_id TEXT NOT NULL,\n scope_id TEXT NOT NULL,\n reason TEXT NOT NULL,\n mode TEXT NOT NULL,\n started_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n -- Explicitly closed, as distinct from expired. Never deleted (K-21): a\n -- session that once existed is why some rows carry the stamp they do.\n ended_at TEXT\n );\n CREATE INDEX IF NOT EXISTS _substrat_impersonations_tenant ON _substrat_impersonations (tenant_id, id);\n CREATE INDEX IF NOT EXISTS _substrat_impersonations_actor ON _substrat_impersonations (actor, id);\n";
|
|
102
|
+
//# sourceMappingURL=impersonation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"impersonation.d.ts","sourceRoot":"","sources":["../src/impersonation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAOL,aAAa,EACb,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,kBAAkB,EACvB,KAAK,OAAO,EACZ,KAAK,eAAe,EACpB,KAAK,OAAO,EACZ,KAAK,QAAQ,EACd,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;GASG;AACH,qBAAa,oBAAqB,SAAQ,aAAa;IACrD,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,eAAe,EACtB,KAAK,EAAE,uBAAuB,EAC9B,GAAG,EAAE,OAAO,GACX,oBAAoB,CAgBtB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,oBAAoB,EAC7B,GAAG,EAAE,OAAO,EACZ,EAAE,CAAC,EAAE;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GAC5C,IAAI,CAmBN;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,oBAAoB,GAAG,SAAS,EACzC,IAAI,EAAE,MAAM,GACX,IAAI,CAMN;AAED,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,oBAAoB,GAAG,kBAAkB,CAEtF;AAED,8FAA8F;AAC9F,eAAO,MAAM,qBAAqB,8FAC2D,CAAC;AAE9F,0DAA0D;AAC1D,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,gBAAgB,GAAG,oBAAoB,CAa/E;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,oBAAoB,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAavF;AAED,6FAA6F;AAC7F,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,sBAAsB,GAAG,MAAM,GAAG;IAC3E,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB,CAKA;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,mBAAmB,GAAG,SAAS,EACvC,GAAG,EAAE,OAAO,GACX;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAA;CAAE,CAgC9C;AAED,qFAAqF;AACrF,eAAO,MAAM,iBAAiB,04BAmB7B,CAAC"}
|