@substrat-run/kernel 0.147.0 → 0.148.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.
Files changed (60) hide show
  1. package/dist/audit-outcome.d.ts +1 -1
  2. package/dist/audit-outcome.d.ts.map +1 -1
  3. package/dist/audit-outcome.js +4 -2
  4. package/dist/audit-outcome.js.map +1 -1
  5. package/dist/export-break.d.ts +137 -0
  6. package/dist/export-break.d.ts.map +1 -0
  7. package/dist/export-break.js +219 -0
  8. package/dist/export-break.js.map +1 -0
  9. package/dist/index.d.ts +15 -7
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +9 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/install-receipts.d.ts +116 -0
  14. package/dist/install-receipts.d.ts.map +1 -0
  15. package/dist/install-receipts.js +162 -0
  16. package/dist/install-receipts.js.map +1 -0
  17. package/dist/map-bounded.d.ts +8 -0
  18. package/dist/map-bounded.d.ts.map +1 -0
  19. package/dist/map-bounded.js +16 -0
  20. package/dist/map-bounded.js.map +1 -0
  21. package/dist/peer.js +1 -1
  22. package/dist/platform-sweep.d.ts +20 -360
  23. package/dist/platform-sweep.d.ts.map +1 -1
  24. package/dist/platform-sweep.js +4 -561
  25. package/dist/platform-sweep.js.map +1 -1
  26. package/dist/schedule-state.d.ts +91 -0
  27. package/dist/schedule-state.d.ts.map +1 -0
  28. package/dist/schedule-state.js +118 -0
  29. package/dist/schedule-state.js.map +1 -0
  30. package/dist/scope-copy-ledger.d.ts +29 -0
  31. package/dist/scope-copy-ledger.d.ts.map +1 -1
  32. package/dist/scope-copy-ledger.js +23 -0
  33. package/dist/scope-copy-ledger.js.map +1 -1
  34. package/dist/scope-copy.d.ts.map +1 -1
  35. package/dist/scope-copy.js +3 -2
  36. package/dist/scope-copy.js.map +1 -1
  37. package/dist/scope-host.d.ts +140 -10
  38. package/dist/scope-host.d.ts.map +1 -1
  39. package/dist/scope-host.js.map +1 -1
  40. package/dist/scope-identity.d.ts +68 -0
  41. package/dist/scope-identity.d.ts.map +1 -0
  42. package/dist/scope-identity.js +75 -0
  43. package/dist/scope-identity.js.map +1 -0
  44. package/dist/scope-store.d.ts +4 -1
  45. package/dist/scope-store.d.ts.map +1 -1
  46. package/dist/scope-store.js.map +1 -1
  47. package/dist/spine-restore.js +1 -1
  48. package/dist/store-kind-releases.d.ts +137 -0
  49. package/dist/store-kind-releases.d.ts.map +1 -0
  50. package/dist/store-kind-releases.js +252 -0
  51. package/dist/store-kind-releases.js.map +1 -0
  52. package/dist/upload-link.d.ts +89 -0
  53. package/dist/upload-link.d.ts.map +1 -0
  54. package/dist/upload-link.js +277 -0
  55. package/dist/upload-link.js.map +1 -0
  56. package/dist/vertical-scope-stores.d.ts +43 -0
  57. package/dist/vertical-scope-stores.d.ts.map +1 -0
  58. package/dist/vertical-scope-stores.js +195 -0
  59. package/dist/vertical-scope-stores.js.map +1 -0
  60. package/package.json +2 -2
@@ -0,0 +1,252 @@
1
+ /**
2
+ * A store kind's releases, its rollouts and the holds that serialize them (D-63, #2300), shared by
3
+ * both adapters' directories.
4
+ *
5
+ * - `store_kind_releases`: a release's bytes were stored (the bytes themselves live outside the
6
+ * directory, keyed by kind and sha256). Insert-only: who uploaded a release, and from which
7
+ * commit, is never rewritten.
8
+ * - `store_kind_rollouts`: one row per attempt to move every script of the kind to a release, in
9
+ * `seq` order. A row is `rolling` until its holder ends it `good`, `failed` or `diverged`.
10
+ * - `store_kind_current`: the release the last `good` rollout moved every script to.
11
+ *
12
+ * "The immediately previous good release" (D-63) is recorded, never derived from an ordering:
13
+ * each rollout row names `previous_good_sha256`, the release a rollback from it returns to. A
14
+ * rollout of T from current F records F; a rollback goes to the current row's previous good X, and
15
+ * records X's own previous good, so after r1 → r2 → r3 one rollback reaches r2 and the next r1.
16
+ *
17
+ * A hold is a lease: `exclusive` for a rollout or a rollback, `shared` for a push or serve that
18
+ * rebuilds a script at the release it runs, which a rollout must not interleave with (a rebuild
19
+ * that read the old release and uploaded after the rollout moved the script would revert it).
20
+ * A begin or finish is refused unless the caller still holds the kind exclusively, so a holder
21
+ * whose lease lapsed cannot write over the next one; its `rolling` row reads as `diverged` to the
22
+ * next rollout, which converges every script again.
23
+ */
24
+ import { substratError } from '@substrat-run/contracts';
25
+ /** Interpolated into both adapters' directory DDL, so `lint:spine-ddl` sees one spelling on each side. */
26
+ export const STORE_KIND_RELEASES_DDL = `
27
+ CREATE TABLE IF NOT EXISTS store_kind_releases (
28
+ kind TEXT NOT NULL,
29
+ sha256 TEXT NOT NULL,
30
+ source_commit TEXT NOT NULL,
31
+ uploaded_by TEXT NOT NULL,
32
+ uploaded_at TEXT NOT NULL,
33
+ PRIMARY KEY (kind, sha256)
34
+ );
35
+ CREATE TABLE IF NOT EXISTS store_kind_rollouts (
36
+ kind TEXT NOT NULL,
37
+ seq INTEGER NOT NULL,
38
+ sha256 TEXT NOT NULL,
39
+ action TEXT NOT NULL,
40
+ from_sha256 TEXT,
41
+ previous_good_sha256 TEXT,
42
+ state TEXT NOT NULL,
43
+ actor TEXT NOT NULL,
44
+ -- The hold the rollout runs under: a rolling row whose holder holds nothing has stopped.
45
+ holder TEXT NOT NULL,
46
+ started_at TEXT NOT NULL,
47
+ ended_at TEXT,
48
+ detail TEXT,
49
+ PRIMARY KEY (kind, seq)
50
+ );
51
+ CREATE TABLE IF NOT EXISTS store_kind_current (
52
+ kind TEXT PRIMARY KEY,
53
+ sha256 TEXT NOT NULL,
54
+ seq INTEGER NOT NULL
55
+ );
56
+ CREATE TABLE IF NOT EXISTS store_kind_holds (
57
+ kind TEXT NOT NULL,
58
+ holder TEXT NOT NULL,
59
+ mode TEXT NOT NULL,
60
+ expires_at INTEGER NOT NULL,
61
+ PRIMARY KEY (kind, holder)
62
+ );
63
+ `;
64
+ const rolloutOf = (r) => ({
65
+ kind: r.kind,
66
+ seq: Number(r.seq),
67
+ sha256: r.sha256,
68
+ action: r.action,
69
+ fromSha256: r.from_sha256,
70
+ previousGoodSha256: r.previous_good_sha256,
71
+ state: r.state,
72
+ actor: r.actor,
73
+ startedAt: r.started_at,
74
+ endedAt: r.ended_at,
75
+ detail: r.detail,
76
+ });
77
+ const ROLLOUT_COLUMNS = 'kind, seq, sha256, action, from_sha256, previous_good_sha256, state, actor, started_at, ended_at, detail';
78
+ function rolloutAt(db, kind, seq) {
79
+ const [row] = db.all(`SELECT ${ROLLOUT_COLUMNS} FROM store_kind_rollouts WHERE kind = ? AND seq = ?`, kind, seq);
80
+ return row ? rolloutOf(row) : undefined;
81
+ }
82
+ /** A stored release, or undefined. */
83
+ export function storeKindReleaseIn(db, kind, sha256) {
84
+ const [row] = db.all('SELECT kind, sha256, source_commit, uploaded_by, uploaded_at FROM store_kind_releases WHERE kind = ? AND sha256 = ?', kind, sha256);
85
+ return row
86
+ ? { kind: row.kind, sha256: row.sha256, sourceCommit: row.source_commit, uploadedBy: row.uploaded_by, uploadedAt: row.uploaded_at }
87
+ : undefined;
88
+ }
89
+ /**
90
+ * Record that a release's bytes are stored. The same sha256 again is the same release, and the
91
+ * row already written is returned as it stands (`created: false`). The same sha256 from another
92
+ * commit is refused (`conflict`): one bundle has one source the reviewer reads.
93
+ */
94
+ export function recordStoreKindReleaseIn(db, input) {
95
+ const held = storeKindReleaseIn(db, input.kind, input.sha256);
96
+ if (held) {
97
+ if (held.sourceCommit !== input.sourceCommit) {
98
+ throw substratError('conflict', `release ${input.sha256} of '${input.kind}' is stored as built from ${held.sourceCommit}, not ${input.sourceCommit}`);
99
+ }
100
+ return { record: held, created: false };
101
+ }
102
+ db.run('INSERT INTO store_kind_releases (kind, sha256, source_commit, uploaded_by, uploaded_at) VALUES (?, ?, ?, ?, ?)', input.kind, input.sha256, input.sourceCommit, input.actor, input.at);
103
+ return { record: storeKindReleaseIn(db, input.kind, input.sha256), created: true };
104
+ }
105
+ /**
106
+ * The kind's current release, every rollout, and where a rollback goes now.
107
+ *
108
+ * With `nowMs`, a `rolling` row whose holder no longer holds the kind exclusively is reported as
109
+ * `diverged` (read-time only, nothing is written): its holder stopped, and its scripts may be on
110
+ * either release. The next rollout's begin writes that state.
111
+ *
112
+ * `rollbackTarget` is, in order:
113
+ * - the target of a rollback that is the newest attempt since the current release, a `failed` one
114
+ * aside, and ended `paused` or `diverged` (`unfinishedRollback`): posting the rollback again
115
+ * continues it, and never turns it around onto the release it is leaving. A failed attempt moved
116
+ * nothing, or returned what it moved, so it changes nothing here. No rollout may begin while a
117
+ * rollback to an earlier release is unfinished, `paused` or `diverged` (`beginStoreKindRolloutIn`):
118
+ * it is finished first, by posting it again. A rollback onto the current release (undoing a
119
+ * diverged rollout) does not block a rollout. A rollback to an earlier release cannot meet the
120
+ * schema rule (the route refuses one below the current schema), but one onto the current release
121
+ * can, and then it never finishes, so going forward must stay open;
122
+ * - the current release itself, when a rollout since it ended `diverged` or `paused`: the fleet may
123
+ * be mixed, and undoing that rollout means converging every script back on the release it was
124
+ * healthy on, never going past it;
125
+ * - otherwise the immediately previous good release.
126
+ * An unfinished attempt never changes what "previous good" means.
127
+ */
128
+ export function storeKindStateIn(db, kind, nowMs) {
129
+ const live = nowMs === undefined
130
+ ? undefined
131
+ : new Set(db.all("SELECT holder FROM store_kind_holds WHERE kind = ? AND mode = 'exclusive' AND expires_at > ?", kind, nowMs).map((h) => h.holder));
132
+ const rows = db.all(`SELECT ${ROLLOUT_COLUMNS}, holder FROM store_kind_rollouts WHERE kind = ? ORDER BY seq`, kind);
133
+ const rollouts = rows.map((r) => live && r.state === 'rolling' && !(r.holder && live.has(r.holder))
134
+ ? rolloutOf({ ...r, state: 'diverged', detail: 'its holder no longer holds the kind; the rollout stopped before it ended' })
135
+ : rolloutOf(r));
136
+ const [current] = db.all('SELECT sha256, seq FROM store_kind_current WHERE kind = ?', kind);
137
+ if (!current)
138
+ return { current: null, rollouts, rollbackTarget: null, unfinishedRollback: null };
139
+ const seq = Number(current.seq);
140
+ const row = rollouts.find((r) => r.seq === seq);
141
+ const previousGoodSha256 = row?.previousGoodSha256 ?? null;
142
+ const since = rollouts.filter((r) => r.seq > seq);
143
+ // A failed attempt moved nothing, or returned what it moved, so it never decides what comes next.
144
+ const newest = since.filter((r) => r.state !== 'failed').at(-1);
145
+ const unfinishedRollback = newest?.action === 'rollback' && (newest.state === 'paused' || newest.state === 'diverged') ? newest.sha256 : null;
146
+ const mixed = since.some((r) => r.state === 'diverged' || r.state === 'paused');
147
+ return {
148
+ current: { sha256: current.sha256, seq, previousGoodSha256 },
149
+ rollouts,
150
+ rollbackTarget: unfinishedRollback ?? (mixed ? current.sha256 : previousGoodSha256),
151
+ unfinishedRollback,
152
+ };
153
+ }
154
+ /**
155
+ * Take, or renew, a hold on a kind. Answers false when another holder's live hold excludes it: an
156
+ * exclusive one for any request, or any one for an exclusive request. A lapsed hold is dropped
157
+ * first. Asking in the other mode than the one held is refused.
158
+ *
159
+ * `renew` only extends a hold the holder still holds: a holder whose lease lapsed gets false, even
160
+ * when nobody took the kind since, because a lapsed holder cannot know what happened meanwhile.
161
+ * With `rolloutSeq` it also answers false unless that rollout is still `rolling`, so a rollout
162
+ * whose row another holder ended stops instead of moving scripts the state no longer describes.
163
+ */
164
+ export function holdStoreKindIn(db, input) {
165
+ if (!(input.ttlMs > 0))
166
+ throw substratError('validation_failed', 'a store-kind hold needs a positive lifetime');
167
+ db.run('DELETE FROM store_kind_holds WHERE kind = ? AND expires_at <= ?', input.kind, input.nowMs);
168
+ const live = db.all('SELECT holder, mode FROM store_kind_holds WHERE kind = ?', input.kind);
169
+ const own = live.find((h) => h.holder === input.holder);
170
+ if (own && own.mode !== input.mode) {
171
+ throw substratError('conflict', `holder ${input.holder} holds '${input.kind}' ${own.mode}, not ${input.mode}`);
172
+ }
173
+ if ((input.renew || input.rolloutSeq !== undefined) && !own)
174
+ return false;
175
+ if (input.rolloutSeq !== undefined && rolloutAt(db, input.kind, input.rolloutSeq)?.state !== 'rolling')
176
+ return false;
177
+ const others = live.filter((h) => h.holder !== input.holder);
178
+ if (input.mode === 'exclusive' ? others.length > 0 : others.some((h) => h.mode === 'exclusive'))
179
+ return false;
180
+ db.run(`INSERT INTO store_kind_holds (kind, holder, mode, expires_at) VALUES (?, ?, ?, ?)
181
+ ON CONFLICT (kind, holder) DO UPDATE SET expires_at = excluded.expires_at`, input.kind, input.holder, input.mode, input.nowMs + input.ttlMs);
182
+ return true;
183
+ }
184
+ /** Drop a hold. Dropping one already gone is not an error. */
185
+ export function releaseStoreKindHoldIn(db, input) {
186
+ db.run('DELETE FROM store_kind_holds WHERE kind = ? AND holder = ?', input.kind, input.holder);
187
+ }
188
+ function assertExclusive(db, kind, holder, nowMs) {
189
+ const [row] = db.all("SELECT 1 AS held FROM store_kind_holds WHERE kind = ? AND holder = ? AND mode = 'exclusive' AND expires_at > ?", kind, holder, nowMs);
190
+ if (!row)
191
+ throw substratError('conflict', `'${kind}' is not held exclusively by ${holder}`);
192
+ }
193
+ /**
194
+ * Begin a rollout or a rollback of a stored release, as the exclusive holder. A rollout left
195
+ * `rolling` by an earlier holder ends `diverged` here: its scripts may be on either release, and
196
+ * this rollout reaches all of them. A rollback must name the current release's previous good one.
197
+ */
198
+ export function beginStoreKindRolloutIn(db, input) {
199
+ assertExclusive(db, input.kind, input.holder, input.nowMs);
200
+ if (!storeKindReleaseIn(db, input.kind, input.sha256)) {
201
+ throw substratError('not_found', `release ${input.sha256} of '${input.kind}' is not stored`);
202
+ }
203
+ db.run(`UPDATE store_kind_rollouts SET state = 'diverged', ended_at = ?, detail = 'its holder stopped before the rollout ended'
204
+ WHERE kind = ? AND state = 'rolling'`, input.at, input.kind);
205
+ const { current, rollbackTarget, unfinishedRollback } = storeKindStateIn(db, input.kind, input.nowMs);
206
+ // A rollback under way to an earlier release blocks every rollout until it ends good. One onto
207
+ // the current release (undoing a diverged rollout) does not: if a script refuses to go below its
208
+ // schema, that rollback can never finish, and going forward is the way out.
209
+ if (input.action === 'rollout' && unfinishedRollback && unfinishedRollback !== current?.sha256) {
210
+ throw substratError('conflict', `a rollback of '${input.kind}' to ${unfinishedRollback} is part way through; post the rollback again to finish it before any rollout`);
211
+ }
212
+ let previousGood;
213
+ if (input.action === 'rollback') {
214
+ if (!current || !rollbackTarget)
215
+ throw substratError('conflict', `'${input.kind}' has no previous good release to roll back to`);
216
+ if (rollbackTarget !== input.sha256) {
217
+ throw substratError('conflict', `a rollback of '${input.kind}' returns to ${rollbackTarget}, not ${input.sha256}`);
218
+ }
219
+ if (input.sha256 === current.sha256) {
220
+ // Undoing a diverged rollout: back onto the current release, whose previous good stays.
221
+ previousGood = current.previousGoodSha256;
222
+ }
223
+ else {
224
+ const [made] = db.all(`SELECT previous_good_sha256 FROM store_kind_rollouts
225
+ WHERE kind = ? AND sha256 = ? AND state = 'good' ORDER BY seq DESC LIMIT 1`, input.kind, input.sha256);
226
+ previousGood = made?.previous_good_sha256 ?? null;
227
+ }
228
+ }
229
+ else {
230
+ // Rolling out the current release again (a resume) keeps where a rollback from it returns to.
231
+ previousGood = current ? (current.sha256 === input.sha256 ? current.previousGoodSha256 : current.sha256) : null;
232
+ }
233
+ const next = Number(db.all('SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM store_kind_rollouts WHERE kind = ?', input.kind)[0].next);
234
+ db.run(`INSERT INTO store_kind_rollouts (${ROLLOUT_COLUMNS}, holder) VALUES (?, ?, ?, ?, ?, ?, 'rolling', ?, ?, NULL, NULL, ?)`, input.kind, next, input.sha256, input.action, current?.sha256 ?? null, previousGood, input.actor, input.at, input.holder);
235
+ return rolloutAt(db, input.kind, next);
236
+ }
237
+ /** End a rollout the caller began, as the exclusive holder. A good one makes its release current. */
238
+ export function finishStoreKindRolloutIn(db, input) {
239
+ assertExclusive(db, input.kind, input.holder, input.nowMs);
240
+ const row = rolloutAt(db, input.kind, input.seq);
241
+ if (!row)
242
+ throw substratError('not_found', `'${input.kind}' has no rollout ${input.seq}`);
243
+ if (row.state !== 'rolling')
244
+ throw substratError('conflict', `rollout ${input.seq} of '${input.kind}' already ended ${row.state}`);
245
+ db.run('UPDATE store_kind_rollouts SET state = ?, ended_at = ?, detail = ? WHERE kind = ? AND seq = ?', input.state, input.at, input.detail, input.kind, input.seq);
246
+ if (input.state === 'good') {
247
+ db.run(`INSERT INTO store_kind_current (kind, sha256, seq) VALUES (?, ?, ?)
248
+ ON CONFLICT (kind) DO UPDATE SET sha256 = excluded.sha256, seq = excluded.seq`, input.kind, row.sha256, input.seq);
249
+ }
250
+ return rolloutAt(db, input.kind, input.seq);
251
+ }
252
+ //# sourceMappingURL=store-kind-releases.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-kind-releases.js","sourceRoot":"","sources":["../src/store-kind-releases.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,89 @@
1
+ import { type AttachmentRecord, type CheckSubject, type Decision, type DomainEventInput, type EntityRef, type Instant, type MintedUploadLink, type PermissionKey, type PrincipalId, type UploadLinkMintInput, type UploadLinkPolicy, type UploadLinkRecord, type UploadLinkView } from '@substrat-run/contracts';
2
+ import type { ScopedSql } from './scope-host.js';
3
+ export declare const UPLOAD_LINK_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_upload_links (\n id TEXT PRIMARY KEY,\n -- SHA-256 hex of the secret. The secret itself is stored NOWHERE.\n token_hash TEXT NOT NULL UNIQUE,\n entity_type TEXT NOT NULL,\n entity_id TEXT NOT NULL,\n -- NULL = the uploader names the file.\n filename TEXT,\n max_bytes INTEGER NOT NULL,\n -- JSON array of media types / families, or NULL = any type.\n content_types TEXT,\n visibility TEXT NOT NULL,\n -- The principal the attachment is written AS: its author, and whose `writePermission`\n -- is checked again at redemption.\n minted_by TEXT NOT NULL,\n minted_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n redeemed_at TEXT,\n -- The attachment this link produced.\n attachment_id TEXT,\n -- Tombstone, never a delete: a link that was minted is evidence of why a file arrived.\n revoked_at TEXT,\n revoked_by TEXT\n );\n CREATE INDEX IF NOT EXISTS _substrat_upload_links_entity\n ON _substrat_upload_links (entity_type, entity_id);\n";
4
+ /** The tables `UPLOAD_LINK_DDL` builds — what a copy of a scope leaves behind. */
5
+ export declare const UPLOAD_LINK_TABLE_NAMES: readonly ['_substrat_upload_links'];
6
+ /** `reason` slugs a redemption refuses with, so the HTTP route can answer 410/413/415 and a client can act. */
7
+ export declare const UPLOAD_LINK_GONE = "upload_link_gone";
8
+ export declare const UPLOAD_LINK_TOO_LARGE = "upload_link_too_large";
9
+ export declare const UPLOAD_LINK_TYPE_REFUSED = "upload_link_type_refused";
10
+ export declare const UPLOAD_LINK_WRONG_TARGET = "upload_link_wrong_target";
11
+ /** A fresh link secret: 256 bits, base64url, prefixed. Only its hash is ever stored. */
12
+ export declare function mintUploadLinkSecret(): string;
13
+ /** Is `type` (already stripped of parameters, lowercase) admitted by `allowed`? `null` admits anything. */
14
+ export declare function uploadContentTypeAllowed(allowed: readonly string[] | null, type: string): boolean;
15
+ /** What an operation handler sees as `ctx.attachments`. */
16
+ export interface AttachmentVerbs {
17
+ /**
18
+ * Mint a single-use URL that lets its holder upload ONE file to `entity` — and return the
19
+ * secret and the path that carries it, once.
20
+ *
21
+ * The target must declare `uploadLinks` in its `attachmentTargets` entry, and the caller
22
+ * must hold the target's `writePermission` on the entity (the check an upload makes);
23
+ * it is made again, as the same principal, when the link is redeemed. Only a principal may
24
+ * mint. The operation's own guards — an entity lock, a state check — run in the operation,
25
+ * before this; they are not re-run at redemption, so keep `ttlSeconds` short.
26
+ *
27
+ * ```ts
28
+ * assertAllowed(await ctx.check('contract:write', ref));
29
+ * const link = await ctx.attachments.mintUploadUrl({ entity: ref, filename: 'signed.pdf' });
30
+ * return { url: `${origin}${link.path}`, expiresAt: link.expiresAt };
31
+ * ```
32
+ *
33
+ * The same tripwire `ctx.capabilities.mint` has applies: while this invocation runs,
34
+ * `ctx.emit` and `ctx.sql` refuse a record carrying the secret verbatim. Transactional with
35
+ * the operation: a mint whose operation throws never happened.
36
+ */
37
+ mintUploadUrl(input: UploadLinkMintInput): Promise<MintedUploadLink>;
38
+ /** Revoke a link that has not been redeemed. Allowed to anyone who could have minted it. Idempotent. */
39
+ revokeUploadLink(id: string): Promise<void>;
40
+ /** This scope's upload links, newest first — never the secret. What a follow-up operation reads to find the attachment a link produced. */
41
+ uploadLinks(filter?: {
42
+ entity?: EntityRef;
43
+ limit?: number;
44
+ }): UploadLinkRecord[];
45
+ }
46
+ /** What the verbs need of the target's declaration: its write key and its link policy. */
47
+ export interface UploadTarget {
48
+ write: PermissionKey;
49
+ uploadLinks?: UploadLinkPolicy;
50
+ }
51
+ export interface AttachmentVerbDeps {
52
+ /** RAW spine access inside the operation's own transaction. */
53
+ sql: ScopedSql;
54
+ subject: CheckSubject;
55
+ now: Instant;
56
+ /** The operation's OWN check. */
57
+ check: (permission: PermissionKey, entity?: EntityRef) => Promise<Decision>;
58
+ emit: (event: DomainEventInput) => void;
59
+ assertWrites: (verb: string) => void;
60
+ /** This invocation's minted secrets — appended to by `mint`, read by the secret guards. */
61
+ minted: string[];
62
+ /** The declared attachment target for an entity type, if any module declares one. */
63
+ targetOf: (entityType: string) => UploadTarget | undefined;
64
+ }
65
+ export declare function createAttachmentVerbs(deps: AttachmentVerbDeps): AttachmentVerbs;
66
+ /** What the route needs before it reads a byte, and who the write is made as. */
67
+ export interface UploadLinkInspection {
68
+ view: UploadLinkView;
69
+ mintedBy: PrincipalId;
70
+ }
71
+ /**
72
+ * Look a secret up without spending it: `not_found` for one that names nothing, `conflict`
73
+ * with reason `upload_link_gone` for one that is used, expired or revoked.
74
+ */
75
+ export declare function inspectUploadLink(deps: {
76
+ sql: ScopedSql;
77
+ now: Instant;
78
+ }, secret: unknown): Promise<UploadLinkInspection>;
79
+ /**
80
+ * Spend the link for `record`, INSIDE the transaction that inserts the attachment row — so a
81
+ * refusal anywhere rolls the spend back. Judges the record against the link, never the other
82
+ * way round: the target, the author, the size and the type are all the link's to refuse.
83
+ */
84
+ export declare function redeemUploadLinkInTransaction(deps: {
85
+ sql: ScopedSql;
86
+ now: Instant;
87
+ emit: (event: DomainEventInput) => void;
88
+ }, secret: unknown, record: AttachmentRecord): Promise<void>;
89
+ //# sourceMappingURL=upload-link.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload-link.d.ts","sourceRoot":"","sources":["../src/upload-link.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,277 @@
1
+ import { ATTACHMENT_UPLOAD_LINK_MINTED, ATTACHMENT_UPLOAD_LINK_REDEEMED, ATTACHMENT_UPLOAD_LINK_REVOKED, UPLOAD_LINK_DEFAULT_TTL_SECONDS, UPLOAD_LINK_MAX_BYTES_CEILING, UPLOAD_LINK_SECRET_PREFIX, principalId as principalIdSchema, substratError, uploadLinkMintInput, uploadLinkPath, } from '@substrat-run/contracts';
2
+ import { toBase64url } from './base64url.js';
3
+ import { capabilityTokenHash } from './capability.js';
4
+ import { assertAllowed } from './permission-checker.js';
5
+ import { ulid } from './ulid.js';
6
+ export const UPLOAD_LINK_DDL = `
7
+ CREATE TABLE IF NOT EXISTS _substrat_upload_links (
8
+ id TEXT PRIMARY KEY,
9
+ -- SHA-256 hex of the secret. The secret itself is stored NOWHERE.
10
+ token_hash TEXT NOT NULL UNIQUE,
11
+ entity_type TEXT NOT NULL,
12
+ entity_id TEXT NOT NULL,
13
+ -- NULL = the uploader names the file.
14
+ filename TEXT,
15
+ max_bytes INTEGER NOT NULL,
16
+ -- JSON array of media types / families, or NULL = any type.
17
+ content_types TEXT,
18
+ visibility TEXT NOT NULL,
19
+ -- The principal the attachment is written AS: its author, and whose \`writePermission\`
20
+ -- is checked again at redemption.
21
+ minted_by TEXT NOT NULL,
22
+ minted_at TEXT NOT NULL,
23
+ expires_at TEXT NOT NULL,
24
+ redeemed_at TEXT,
25
+ -- The attachment this link produced.
26
+ attachment_id TEXT,
27
+ -- Tombstone, never a delete: a link that was minted is evidence of why a file arrived.
28
+ revoked_at TEXT,
29
+ revoked_by TEXT
30
+ );
31
+ CREATE INDEX IF NOT EXISTS _substrat_upload_links_entity
32
+ ON _substrat_upload_links (entity_type, entity_id);
33
+ `;
34
+ /** The tables `UPLOAD_LINK_DDL` builds — what a copy of a scope leaves behind. */
35
+ export const UPLOAD_LINK_TABLE_NAMES = ['_substrat_upload_links'];
36
+ /** `reason` slugs a redemption refuses with, so the HTTP route can answer 410/413/415 and a client can act. */
37
+ export const UPLOAD_LINK_GONE = 'upload_link_gone';
38
+ export const UPLOAD_LINK_TOO_LARGE = 'upload_link_too_large';
39
+ export const UPLOAD_LINK_TYPE_REFUSED = 'upload_link_type_refused';
40
+ export const UPLOAD_LINK_WRONG_TARGET = 'upload_link_wrong_target';
41
+ const COLUMNS = 'id, entity_type, entity_id, filename, max_bytes, content_types, visibility, minted_by, minted_at, ' +
42
+ 'expires_at, redeemed_at, attachment_id, revoked_at, revoked_by';
43
+ /** A fresh link secret: 256 bits, base64url, prefixed. Only its hash is ever stored. */
44
+ export function mintUploadLinkSecret() {
45
+ return UPLOAD_LINK_SECRET_PREFIX + toBase64url(crypto.getRandomValues(new Uint8Array(32)));
46
+ }
47
+ /** A secret that cannot be one of ours is refused before it is hashed. */
48
+ const plausibleSecret = (secret) => typeof secret === 'string' && secret.startsWith(UPLOAD_LINK_SECRET_PREFIX) && secret.length <= 128;
49
+ /** Is `type` (already stripped of parameters, lowercase) admitted by `allowed`? `null` admits anything. */
50
+ export function uploadContentTypeAllowed(allowed, type) {
51
+ if (allowed === null)
52
+ return true;
53
+ const t = type.toLowerCase();
54
+ return allowed.some((a) => (a.endsWith('/*') ? t.startsWith(a.slice(0, -1)) : a === t));
55
+ }
56
+ const stateOf = (row, now) => row.revoked_at !== null ? 'revoked' : row.redeemed_at !== null ? 'redeemed' : row.expires_at <= now ? 'expired' : 'pending';
57
+ function recordOf(row, now) {
58
+ return {
59
+ id: row.id,
60
+ entity: { entityType: row.entity_type, entityId: row.entity_id },
61
+ filename: row.filename,
62
+ maxBytes: row.max_bytes,
63
+ contentTypes: row.content_types === null ? null : JSON.parse(row.content_types),
64
+ visibility: row.visibility,
65
+ mintedBy: principalIdSchema.parse(row.minted_by),
66
+ mintedAt: row.minted_at,
67
+ expiresAt: row.expires_at,
68
+ state: stateOf(row, now),
69
+ redeemedAt: row.redeemed_at,
70
+ attachmentId: row.attachment_id,
71
+ };
72
+ }
73
+ const MS_PER_SECOND = 1000;
74
+ function principalOf(subject, verb) {
75
+ if (subject.kind !== 'principal') {
76
+ throw substratError('forbidden', `${verb}: only a principal may mint or revoke an upload link — a ${subject.kind} actor has no ` +
77
+ 'authorship to lend an attachment', { reason: 'upload_link_delegation' });
78
+ }
79
+ return subject.id;
80
+ }
81
+ export function createAttachmentVerbs(deps) {
82
+ const readRow = (id) => deps.sql.query(`SELECT ${COLUMNS} FROM _substrat_upload_links WHERE id = ?`, [id])[0];
83
+ return {
84
+ async mintUploadUrl(raw) {
85
+ deps.assertWrites('ctx.attachments.mintUploadUrl');
86
+ const minter = principalOf(deps.subject, 'ctx.attachments.mintUploadUrl');
87
+ const input = uploadLinkMintInput.parse(raw);
88
+ const target = deps.targetOf(input.entity.entityType);
89
+ if (!target) {
90
+ throw substratError('validation_failed', `ctx.attachments.mintUploadUrl: '${input.entity.entityType}' is not a declared attachment target`);
91
+ }
92
+ const policy = target.uploadLinks;
93
+ if (!policy) {
94
+ throw substratError('forbidden', `ctx.attachments.mintUploadUrl: '${input.entity.entityType}' does not declare uploadLinks in its attachmentTargets entry`, { reason: 'upload_links_not_declared' });
95
+ }
96
+ const maxBytes = input.maxBytes ?? policy.maxBytes;
97
+ if (maxBytes > policy.maxBytes || maxBytes > UPLOAD_LINK_MAX_BYTES_CEILING) {
98
+ throw substratError('validation_failed', `ctx.attachments.mintUploadUrl: maxBytes ${maxBytes} is above the target's ${policy.maxBytes} — a link narrows its target's policy, never widens it`);
99
+ }
100
+ // Delegation, never elevation: the key an upload checks, on the entity, as the caller.
101
+ assertAllowed(await deps.check(target.write, input.entity));
102
+ const id = ulid();
103
+ const secret = mintUploadLinkSecret();
104
+ const expiresAt = new Date(Date.parse(deps.now) + (input.ttlSeconds ?? UPLOAD_LINK_DEFAULT_TTL_SECONDS) * MS_PER_SECOND).toISOString();
105
+ const visibility = input.visibility ?? 'internal';
106
+ const contentTypes = policy.contentTypes ?? null;
107
+ deps.sql.exec(`INSERT INTO _substrat_upload_links
108
+ (id, token_hash, entity_type, entity_id, filename, max_bytes, content_types, visibility,
109
+ minted_by, minted_at, expires_at)
110
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
111
+ id,
112
+ await capabilityTokenHash(secret),
113
+ input.entity.entityType,
114
+ input.entity.entityId,
115
+ input.filename ?? null,
116
+ maxBytes,
117
+ contentTypes === null ? null : JSON.stringify(contentTypes),
118
+ visibility,
119
+ minter,
120
+ deps.now,
121
+ expiresAt,
122
+ ]);
123
+ deps.minted.push(secret);
124
+ const payload = {
125
+ linkId: id,
126
+ entity: input.entity,
127
+ filename: input.filename ?? null,
128
+ maxBytes,
129
+ contentTypes,
130
+ visibility,
131
+ expiresAt,
132
+ mintedBy: minter,
133
+ };
134
+ deps.emit({
135
+ type: ATTACHMENT_UPLOAD_LINK_MINTED,
136
+ schemaVersion: 1,
137
+ entity: input.entity,
138
+ piiClass: 'none',
139
+ payload,
140
+ });
141
+ return {
142
+ id,
143
+ secret,
144
+ path: uploadLinkPath(input.entity, secret),
145
+ expiresAt,
146
+ maxBytes,
147
+ filename: input.filename ?? null,
148
+ };
149
+ },
150
+ async revokeUploadLink(id) {
151
+ deps.assertWrites('ctx.attachments.revokeUploadLink');
152
+ const revoker = principalOf(deps.subject, 'ctx.attachments.revokeUploadLink');
153
+ const row = readRow(id);
154
+ if (!row)
155
+ throw substratError('not_found', `no upload link ${id} in this scope`);
156
+ const entity = { entityType: row.entity_type, entityId: row.entity_id };
157
+ const target = deps.targetOf(row.entity_type);
158
+ if (target)
159
+ assertAllowed(await deps.check(target.write, entity));
160
+ // A spent or already-revoked link has nothing left to take back.
161
+ if (row.revoked_at !== null || row.redeemed_at !== null)
162
+ return;
163
+ deps.sql.exec(`UPDATE _substrat_upload_links SET revoked_at = ?, revoked_by = ? WHERE id = ?`, [
164
+ deps.now,
165
+ revoker,
166
+ id,
167
+ ]);
168
+ const payload = { linkId: id, entity, revokedBy: revoker };
169
+ deps.emit({
170
+ type: ATTACHMENT_UPLOAD_LINK_REVOKED,
171
+ schemaVersion: 1,
172
+ entity,
173
+ piiClass: 'none',
174
+ payload,
175
+ });
176
+ },
177
+ uploadLinks(filter) {
178
+ const params = [];
179
+ let where = '';
180
+ if (filter?.entity) {
181
+ where = 'WHERE entity_type = ? AND entity_id = ?';
182
+ params.push(filter.entity.entityType, filter.entity.entityId);
183
+ }
184
+ const limit = Math.min(Math.max(Math.trunc(filter?.limit ?? 50), 1), 200);
185
+ params.push(limit);
186
+ return deps.sql
187
+ .query(`SELECT ${COLUMNS} FROM _substrat_upload_links ${where} ORDER BY id DESC LIMIT ?`, params)
188
+ .map((r) => recordOf(r, deps.now));
189
+ },
190
+ };
191
+ }
192
+ // ---------------------------------------------------------------------------
193
+ // Redemption — the host's half. A secret in, an attachment out.
194
+ // ---------------------------------------------------------------------------
195
+ /** One answer for every secret that is not ours or names nothing, so a probe learns nothing it can act on. */
196
+ const unknownLink = () => substratError('not_found', 'This upload link is not valid.');
197
+ /** The 410: it was ours, and it is spent, expired or revoked. Said only to a holder of the secret. */
198
+ const goneLink = () => substratError('conflict', 'This upload link has been used, has expired or was revoked.', { reason: UPLOAD_LINK_GONE });
199
+ async function liveRow(sql, now, secret) {
200
+ if (!plausibleSecret(secret))
201
+ throw unknownLink();
202
+ const row = sql.query(`SELECT ${COLUMNS} FROM _substrat_upload_links WHERE token_hash = ?`, [await capabilityTokenHash(secret)])[0];
203
+ if (!row)
204
+ throw unknownLink();
205
+ if (stateOf(row, now) !== 'pending')
206
+ throw goneLink();
207
+ return row;
208
+ }
209
+ /**
210
+ * Look a secret up without spending it: `not_found` for one that names nothing, `conflict`
211
+ * with reason `upload_link_gone` for one that is used, expired or revoked.
212
+ */
213
+ export async function inspectUploadLink(deps, secret) {
214
+ const row = await liveRow(deps.sql, deps.now, secret);
215
+ const r = recordOf(row, deps.now);
216
+ return {
217
+ view: {
218
+ entity: r.entity,
219
+ filename: r.filename,
220
+ maxBytes: r.maxBytes,
221
+ contentTypes: r.contentTypes,
222
+ visibility: r.visibility,
223
+ },
224
+ mintedBy: r.mintedBy,
225
+ };
226
+ }
227
+ /**
228
+ * Spend the link for `record`, INSIDE the transaction that inserts the attachment row — so a
229
+ * refusal anywhere rolls the spend back. Judges the record against the link, never the other
230
+ * way round: the target, the author, the size and the type are all the link's to refuse.
231
+ */
232
+ export async function redeemUploadLinkInTransaction(deps, secret, record) {
233
+ const row = await liveRow(deps.sql, deps.now, secret);
234
+ if (row.entity_type !== record.entity.entityType || row.entity_id !== record.entity.entityId) {
235
+ // The path named another entity than the link was minted for: nothing is spent.
236
+ throw substratError('not_found', 'This upload link is not valid for that target.', {
237
+ reason: UPLOAD_LINK_WRONG_TARGET,
238
+ });
239
+ }
240
+ if (record.createdBy !== row.minted_by) {
241
+ throw substratError('internal', 'an upload link was redeemed as someone other than its minter');
242
+ }
243
+ if (record.size > row.max_bytes) {
244
+ throw substratError('validation_failed', `The file is ${record.size} bytes; this link admits at most ${row.max_bytes}.`, {
245
+ reason: UPLOAD_LINK_TOO_LARGE,
246
+ });
247
+ }
248
+ const allowed = row.content_types === null ? null : JSON.parse(row.content_types);
249
+ if (!uploadContentTypeAllowed(allowed, record.contentType)) {
250
+ throw substratError('validation_failed', `A file of type ${record.contentType} is not admitted by this link.`, {
251
+ reason: UPLOAD_LINK_TYPE_REFUSED,
252
+ });
253
+ }
254
+ if (row.filename !== null && record.filename !== row.filename) {
255
+ throw substratError('internal', 'an upload link with a fixed file name was redeemed under another');
256
+ }
257
+ const spent = deps.sql.exec(`UPDATE _substrat_upload_links SET redeemed_at = ?, attachment_id = ?
258
+ WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL`, [deps.now, record.id, row.id]);
259
+ if (spent.changes !== 1)
260
+ throw goneLink();
261
+ const payload = {
262
+ linkId: row.id,
263
+ entity: record.entity,
264
+ attachmentId: record.id,
265
+ size: record.size,
266
+ sha256: record.sha256,
267
+ mintedBy: principalIdSchema.parse(row.minted_by),
268
+ };
269
+ deps.emit({
270
+ type: ATTACHMENT_UPLOAD_LINK_REDEEMED,
271
+ schemaVersion: 1,
272
+ entity: record.entity,
273
+ piiClass: 'none',
274
+ payload,
275
+ });
276
+ }
277
+ //# sourceMappingURL=upload-link.js.map