@filelayer/core 0.3.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 (69) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +202 -0
  3. package/MIGRATIONS.md +328 -0
  4. package/NOTICE +37 -0
  5. package/README.md +343 -0
  6. package/SEMANTICS.md +729 -0
  7. package/dist/authz.d.ts +524 -0
  8. package/dist/authz.d.ts.map +1 -0
  9. package/dist/authz.js +889 -0
  10. package/dist/authz.js.map +1 -0
  11. package/dist/db.d.ts +145 -0
  12. package/dist/db.d.ts.map +1 -0
  13. package/dist/db.js +217 -0
  14. package/dist/db.js.map +1 -0
  15. package/dist/delivery.d.ts +293 -0
  16. package/dist/delivery.d.ts.map +1 -0
  17. package/dist/delivery.js +519 -0
  18. package/dist/delivery.js.map +1 -0
  19. package/dist/errors.d.ts +16 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +21 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/filelayer.d.ts +542 -0
  24. package/dist/filelayer.d.ts.map +1 -0
  25. package/dist/filelayer.js +1360 -0
  26. package/dist/filelayer.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/simple.d.ts +297 -0
  32. package/dist/simple.d.ts.map +1 -0
  33. package/dist/simple.js +492 -0
  34. package/dist/simple.js.map +1 -0
  35. package/dist/storage.d.ts +269 -0
  36. package/dist/storage.d.ts.map +1 -0
  37. package/dist/storage.js +700 -0
  38. package/dist/storage.js.map +1 -0
  39. package/dist/store.d.ts +432 -0
  40. package/dist/store.d.ts.map +1 -0
  41. package/dist/store.js +862 -0
  42. package/dist/store.js.map +1 -0
  43. package/package.json +77 -0
  44. package/schema.sql +1190 -0
  45. package/src/authz.ts +1398 -0
  46. package/src/db.ts +271 -0
  47. package/src/delivery.ts +737 -0
  48. package/src/errors.ts +24 -0
  49. package/src/filelayer.ts +1836 -0
  50. package/src/index.ts +7 -0
  51. package/src/simple.ts +666 -0
  52. package/src/storage.ts +917 -0
  53. package/src/store.ts +1072 -0
  54. package/test/delivery.test.ts +0 -0
  55. package/test/group-subjects.test.ts +1072 -0
  56. package/test/helpers.ts +65 -0
  57. package/test/listing.test.ts +689 -0
  58. package/test/local-s3.d.mts +33 -0
  59. package/test/local-s3.mjs +400 -0
  60. package/test/persistence.test.ts +953 -0
  61. package/test/regression.test.ts +619 -0
  62. package/test/s3-live.test.ts +322 -0
  63. package/test/security.test.ts +1652 -0
  64. package/test/semantics.test.ts +888 -0
  65. package/test/storage.test.ts +437 -0
  66. package/test/tiers.test.ts +432 -0
  67. package/test/vault-example.test.ts +302 -0
  68. package/tsconfig.build.json +29 -0
  69. package/tsconfig.json +19 -0
@@ -0,0 +1,1836 @@
1
+ /**
2
+ * FILELAYER -- the surface an application developer actually touches.
3
+ *
4
+ * Design rule for this file: every method that touches a file or an org calls
5
+ * into the authorization engine and does nothing before it. There is no
6
+ * "internal" variant that skips the check, because the moment such a variant
7
+ * exists someone will call it from a route handler at 6pm on a Friday.
8
+ *
9
+ * SECOND design rule, added after the security review: this file contains no
10
+ * security LOGIC, only security PLUMBING. Every rule that used to live here as
11
+ * a "patch at the wrong layer" has moved into `authz.ts` or `schema.sql`:
12
+ *
13
+ * - capability attenuation on share -> authorizeShare() + a BEFORE
14
+ * INSERT trigger on file_grant
15
+ * - the 410/409 existence-oracle downgrade
16
+ * and its `hasStanding()` helper -> evaluation order in authorize()
17
+ * - the membership / viewer checks in
18
+ * upload() -> authorizeOrg('create_file')
19
+ * - the admin check in auditLog() -> authorizeOrg('read_audit')
20
+ * - the unaudited early return in redeem()
21
+ * for unknown secrets -> the engine's system chain
22
+ *
23
+ * Errors are thrown as `FilelayerError`, already collapsed through
24
+ * `toPublicError`, so the developer cannot accidentally return our internal
25
+ * deny reason to an attacker. That collapsing is a security-sensitive decision
26
+ * we make once, here, instead of asking the developer to make it per-route.
27
+ */
28
+
29
+ import { randomBytes, randomUUID } from 'node:crypto';
30
+ import {
31
+ auditUnresolvedSecret,
32
+ authorize,
33
+ authorizeList,
34
+ authorizeMembershipChange,
35
+ authorizeOrg,
36
+ authorizeRevoke,
37
+ authorizeShare,
38
+ schemaRefusal,
39
+ toPublicError,
40
+ type Capability,
41
+ type Decision,
42
+ type FileRef,
43
+ type FileVisibility,
44
+ type GrantSubjectType,
45
+ type OrgRole,
46
+ type Principal,
47
+ } from './authz.ts';
48
+ import {
49
+ CommitThenThrow,
50
+ withTransaction,
51
+ type Queryable,
52
+ type Tx,
53
+ } from './db.ts';
54
+ import {
55
+ PostgresStore,
56
+ DEFAULT_PROJECT_ID,
57
+ isUuid,
58
+ toCapabilities,
59
+ LIST_DEFAULT_LIMIT,
60
+ LIST_MAX_LIMIT,
61
+ type AuditRow,
62
+ type AuditChainResult,
63
+ } from './store.ts';
64
+ import {
65
+ canList,
66
+ canPresign,
67
+ collectStream,
68
+ type ObjectStream,
69
+ type PutBody,
70
+ type StorageAdapter,
71
+ } from './storage.ts';
72
+ import { FilesApi, OrgsApi, SharesApi } from './simple.ts';
73
+ import {
74
+ contentDisposition,
75
+ deliveryHeaders,
76
+ isActiveContentType,
77
+ redirectHeaders,
78
+ resolveRedirectConfig,
79
+ safeContentType,
80
+ type Disposition,
81
+ type ProxyDelivery,
82
+ type RedirectDelivery,
83
+ type RedirectDeliveryConfig,
84
+ type ResolvedRedirectConfig,
85
+ type StreamedDelivery,
86
+ } from './delivery.ts';
87
+ import { FilelayerError } from './errors.ts';
88
+
89
+ // Declared in `errors.ts` so that `delivery.ts` can classify errors without
90
+ // importing this module (which imports `delivery.ts`). Re-exported here because
91
+ // this is where every existing caller imports it from.
92
+ export { FilelayerError };
93
+
94
+ export interface UploadInput {
95
+ name: string;
96
+ contentType: string;
97
+ /**
98
+ * Advisory. The AUTHORITATIVE size is what the adapter reports it actually
99
+ * wrote, and that is what lands in `size_bytes`. A caller-supplied size that
100
+ * disagrees with the object is how a `content-length` ends up truncating a
101
+ * download.
102
+ */
103
+ size?: number;
104
+ /**
105
+ * Bytes, or a stream of bytes.
106
+ *
107
+ * A `Uint8Array` is the convenient form for the small-file case that
108
+ * dominates (avatars, PDFs, attachments) and is kept for exactly that reason.
109
+ * A `ReadableStream` is the form that does not put the whole object on the
110
+ * heap: with the S3 adapter it becomes a multipart upload whose peak memory
111
+ * is one part, whatever the object's size.
112
+ */
113
+ body: PutBody;
114
+ /**
115
+ * Who, inside the owning org, can see this file before anybody shares it.
116
+ *
117
+ * 'private' (DEFAULT) -- owner + org admins/owners only. Everyone else
118
+ * needs an explicit grant.
119
+ * 'org' -- every member of the org may read it.
120
+ *
121
+ * The default is the restrictive one. See schema.sql, `file_visibility`.
122
+ */
123
+ visibility?: FileVisibility;
124
+ /** Seconds until the file itself expires (lifecycle, not a grant). */
125
+ expiresIn?: number;
126
+ /** Seconds of retention floor: deletion is blocked until it passes. */
127
+ retainFor?: number;
128
+ metadata?: Record<string, unknown>;
129
+ }
130
+
131
+ /**
132
+ * WHO a grant is for. A grant's subject is a PRINCIPAL SET (RFC-001):
133
+ *
134
+ * actor exactly one person
135
+ * role every member of an org at role >= `minRole`
136
+ * org every member of an org, at any role
137
+ * link whoever holds the secret (bearer, not identity)
138
+ * anonymous everyone
139
+ *
140
+ * `org` and `role` may name an org OTHER than the file's own -- that is the
141
+ * point ("the company that posted this job may read this CV") -- but it must be
142
+ * an org in the same PROJECT. Cross-project is unrepresentable, by composite
143
+ * foreign key, and refused here with a 404 before it gets that far.
144
+ *
145
+ * I6: an issuer whose own authority came from a GRANT may only mint `actor` or
146
+ * `link`. See `authorizeShare`.
147
+ */
148
+ export type ShareSubject =
149
+ | { type: 'link' }
150
+ | { type: 'anonymous' }
151
+ | { type: 'actor'; actorId: string }
152
+ /** Every member of `orgId`, at any role. */
153
+ | { type: 'org'; orgId: string }
154
+ /** Every member of `orgId` at `minRole` or above. */
155
+ | { type: 'role'; orgId: string; minRole: OrgRole };
156
+
157
+ export interface ShareInput {
158
+ subject: ShareSubject;
159
+ capabilities?: Capability[];
160
+ expiresIn?: number;
161
+ maxDownloads?: number;
162
+ password?: string;
163
+ }
164
+
165
+ export interface ShareResult {
166
+ grantId: string;
167
+ /** Returned exactly once. Only its SHA-256 is persisted. */
168
+ secret?: string;
169
+ url?: string;
170
+ /**
171
+ * The EFFECTIVE lifetime and cap, after attenuation against the parent grant.
172
+ * A delegated share can never exceed the authority it came from, so these may
173
+ * be tighter than what was asked for. They are returned rather than silently
174
+ * applied so that the clamp is visible to the caller.
175
+ */
176
+ expiresAt: Date | null;
177
+ maxDownloads: number | null;
178
+ /** Non-null when this grant was delegated from another grant (P4). */
179
+ parentGrantId: string | null;
180
+ }
181
+
182
+ export interface FileRecord extends FileRef {
183
+ name: string;
184
+ contentType: string;
185
+ sizeBytes: number | null;
186
+ /**
187
+ * WHICH store the bytes are in. This column used to be written as the literal
188
+ * `'memory'` on every insert regardless of the configured adapter, which
189
+ * meant a production deployment recorded every object as living in an
190
+ * in-process Map. It participates in `file_storage_key_idx`
191
+ * (UNIQUE (storage_provider, storage_key)), so it is half of an object's
192
+ * identity, not a label.
193
+ */
194
+ storageProvider: string;
195
+ storageKey: string;
196
+ createdAt: Date;
197
+ }
198
+
199
+ /**
200
+ * A COMMITTED delivery decision, waiting for bytes.
201
+ *
202
+ * The split between this and the bytes is the whole shape of the fix: the
203
+ * decision, the download charge and the audit event commit as one unit, and
204
+ * only then does anything touch the object store -- which can take minutes and
205
+ * must not hold a database connection while it does.
206
+ */
207
+ type Reservation =
208
+ | {
209
+ kind: 'proxy';
210
+ file: FileRecord;
211
+ headers: Record<string, string>;
212
+ remainingDownloads: number | null;
213
+ grantId: string | null;
214
+ }
215
+ | {
216
+ kind: 'redirect';
217
+ file: FileRecord;
218
+ headers: Record<string, string>;
219
+ remainingDownloads: number | null;
220
+ grantId: string | null;
221
+ url: string;
222
+ expiresAt: Date;
223
+ ttlSeconds: number;
224
+ cacheable: boolean;
225
+ };
226
+
227
+ export interface GrantSummary {
228
+ id: string;
229
+ fileId: string;
230
+ parentGrantId: string | null;
231
+ subjectType: GrantSubjectType;
232
+ subjectId: string | null;
233
+ /** The org whose members are the subject, for 'org' and 'role' grants. */
234
+ subjectOrgId: string | null;
235
+ /** The role floor, for 'role' grants. Null on 'org' reads as 'viewer'. */
236
+ subjectMinRole: OrgRole | null;
237
+ capabilities: Capability[];
238
+ hasPassword: boolean;
239
+ expiresAt: Date | null;
240
+ maxDownloads: number | null;
241
+ downloadCount: number;
242
+ revokedAt: Date | null;
243
+ /** Recursive liveness: false if this grant OR any ancestor is dead. */
244
+ live: boolean;
245
+ createdBy: string | null;
246
+ createdAt: Date;
247
+ }
248
+
249
+ export interface FilelayerOptions {
250
+ baseUrl?: string;
251
+ /**
252
+ * The customer application this instance speaks for (P8).
253
+ *
254
+ * In a hosted deployment the API layer resolves a project from the request's API
255
+ * key and constructs one of these bound to it. A bound instance cannot see,
256
+ * list, audit or address anything in another project. Omit it and everything
257
+ * lands in the default project, which is the correct behaviour for a
258
+ * single-project deployment. Pass `null` for the control plane.
259
+ */
260
+ projectId?: string | null;
261
+
262
+ /**
263
+ * OPT-IN REDIRECT DELIVERY. Absent means every delivery is proxied, which is
264
+ * the only mode with an unqualified "revocation is immediate" guarantee.
265
+ *
266
+ * Read the DELIVERY MODES block at the top of `delivery.ts` before setting
267
+ * this. It will not typecheck without the acknowledgement string, and the
268
+ * acknowledgement string says what you are accepting.
269
+ */
270
+ redirectDelivery?: RedirectDeliveryConfig;
271
+ }
272
+
273
+ export class Filelayer {
274
+ readonly store: PostgresStore;
275
+
276
+ private readonly db: Queryable;
277
+ private readonly storage: StorageAdapter;
278
+ private readonly opts: FilelayerOptions;
279
+
280
+ private _files?: FilesApi;
281
+ private _orgs?: OrgsApi;
282
+ private _shares?: SharesApi;
283
+
284
+ /** Null unless redirect delivery was configured AND acknowledged. */
285
+ private readonly redirect: ResolvedRedirectConfig | null;
286
+
287
+ constructor(db: Queryable, storage: StorageAdapter, opts: FilelayerOptions = {}) {
288
+ this.db = db;
289
+ this.storage = storage;
290
+ this.opts = opts;
291
+ // A provider name is half of an object's primary identity (see the UNIQUE
292
+ // index). An adapter that does not supply one is a configuration error, not
293
+ // a default to guess at -- guessing is how it became 'memory' in the first
294
+ // place.
295
+ if (typeof storage.provider !== 'string' || storage.provider.length === 0) {
296
+ throw new Error('storage adapter must declare a non-empty `provider`');
297
+ }
298
+ this.store = new PostgresStore(db, {
299
+ ...(opts.projectId !== undefined ? { projectId: opts.projectId } : {}),
300
+ });
301
+ this.redirect = opts.redirectDelivery ? resolveRedirectConfig(opts.redirectDelivery) : null;
302
+ }
303
+
304
+ /**
305
+ * Run a unit of work in one transaction, on one connection.
306
+ *
307
+ * `fn` receives a store bound to the transaction, so the audit events the
308
+ * engine writes and the mutation they describe commit together -- and so
309
+ * `audit_append()`'s advisory lock, which is a `pg_advisory_XACT_lock`, is
310
+ * held across the whole unit rather than across one autocommit statement.
311
+ *
312
+ * THE ONE SUBTLETY, AND IT IS THE IMPORTANT ONE.
313
+ *
314
+ * A DENIAL writes an audit event and then throws. If a throw always rolled
315
+ * back we would lose exactly the events P5 exists to keep, silently, while
316
+ * the caller still saw their 403 -- an audit log that omits refusals is worse
317
+ * than no audit log, because it looks complete.
318
+ *
319
+ * So `FilelayerError` -- and ONLY `FilelayerError` -- is treated as a DECIDED
320
+ * outcome: commit, then throw. Every `FilelayerError` this library raises is
321
+ * a decision or a lookup miss, never a half-applied mutation; the one place
322
+ * that could have been (the schema attenuation backstop in `share()`) uses a
323
+ * SAVEPOINT so the failed INSERT is undone before the deny event is written.
324
+ * Anything else -- a driver error, an unanticipated constraint, a bug --
325
+ * rolls the whole unit back.
326
+ */
327
+ #transaction<T>(fn: (tx: Tx, store: PostgresStore) => Promise<T>): Promise<T> {
328
+ return withTransaction(this.db, async (tx) => {
329
+ try {
330
+ return await fn(tx, this.store.withDb(tx));
331
+ } catch (err) {
332
+ if (err instanceof FilelayerError) throw new CommitThenThrow(err);
333
+ throw err;
334
+ }
335
+ });
336
+ }
337
+
338
+ /**
339
+ * A throwaway, in-process instance: PGlite + in-memory bytes.
340
+ *
341
+ * For a five-minute first run and for tests. **Everything is lost when the
342
+ * process exits** -- there is no file on disk and no bucket. Production is
343
+ * `new Filelayer(pgPool, new S3Storage({...}), { baseUrl })`; see
344
+ * docs/QUICKSTART.md, which does not hide the three configuration steps.
345
+ */
346
+ static async quickstart(opts: { baseUrl?: string } = {}): Promise<Filelayer> {
347
+ const { createTestDb } = await import('./db.ts');
348
+ const { MemoryStorage } = await import('./storage.ts');
349
+ const { db } = await createTestDb();
350
+ return new Filelayer(db, new MemoryStorage(), {
351
+ baseUrl: opts.baseUrl ?? 'http://localhost:3000',
352
+ });
353
+ }
354
+
355
+ /** Where public URLs are rooted. Read-only; set once at construction. */
356
+ get baseUrl(): string | undefined {
357
+ return this.opts.baseUrl;
358
+ }
359
+
360
+ /** The project this instance is bound to. Null means unscoped. */
361
+ get projectId(): string | null {
362
+ return this.store.projectId;
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // The tiered surface (see src/simple.ts). Purely additive: every method below
367
+ // this line is unchanged, and the facade calls into it rather than around it.
368
+ // ---------------------------------------------------------------------------
369
+
370
+ get files(): FilesApi {
371
+ return (this._files ??= new FilesApi(this));
372
+ }
373
+
374
+ get orgs(): OrgsApi {
375
+ return (this._orgs ??= new OrgsApi(this));
376
+ }
377
+
378
+ get shares(): SharesApi {
379
+ return (this._shares ??= new SharesApi(this));
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Tenancy
384
+ // ---------------------------------------------------------------------------
385
+
386
+ /**
387
+ * Create an organization, optionally with its first owner.
388
+ *
389
+ * Creating a tenant is a control-plane operation: there is no principal
390
+ * inside the system yet who could be authorized to do it, and pretending
391
+ * otherwise would be theatre. Passing `ownerActorId` closes the bootstrap
392
+ * gap that would otherwise exist -- an org is never memberless, so there is
393
+ * never a "the org has no members yet, let anyone in" path for an attacker to
394
+ * find. Every subsequent membership change is authorized (see `addMember`).
395
+ */
396
+ async createOrg(
397
+ externalId: string,
398
+ name?: string,
399
+ opts: { ownerActorId?: string } = {},
400
+ ): Promise<{ id: string }> {
401
+ const { rows } = await this.db.query<{ id: string }>(
402
+ `INSERT INTO org (project_id, external_id, name)
403
+ VALUES (coalesce($3::uuid, '${DEFAULT_PROJECT_ID}'::uuid), $1, $2) RETURNING id`,
404
+ [externalId, name ?? null, this.projectId],
405
+ );
406
+ const id = rows[0]!.id;
407
+
408
+ if (opts.ownerActorId) {
409
+ await this.db.query(
410
+ `INSERT INTO membership (org_id, actor_id, role) VALUES ($1,$2,'owner')`,
411
+ [id, opts.ownerActorId],
412
+ );
413
+ await this.store.audit({
414
+ orgId: id,
415
+ action: 'member.bootstrap',
416
+ decision: 'allow',
417
+ actorId: opts.ownerActorId,
418
+ fileId: null,
419
+ context: { targetActorId: opts.ownerActorId, toRole: 'owner', via: 'org.create' },
420
+ });
421
+ }
422
+ return { id };
423
+ }
424
+
425
+ async createActor(externalId: string): Promise<{ id: string }> {
426
+ const { rows } = await this.db.query<{ id: string }>(
427
+ `INSERT INTO actor (project_id, external_id)
428
+ VALUES (coalesce($2::uuid, '${DEFAULT_PROJECT_ID}'::uuid), $1) RETURNING id`,
429
+ [externalId, this.projectId],
430
+ );
431
+ return { id: rows[0]!.id };
432
+ }
433
+
434
+ /**
435
+ * Register a customer application. Control plane; see the note on the
436
+ * lifecycle methods below for why these three take no principal.
437
+ */
438
+ async createProject(key: string, name?: string): Promise<{ id: string }> {
439
+ const { rows } = await this.db.query<{ id: string }>(
440
+ `INSERT INTO project (key, name) VALUES ($1, $2)
441
+ ON CONFLICT (key) DO UPDATE SET key = EXCLUDED.key
442
+ RETURNING id`,
443
+ [key, name ?? null],
444
+ );
445
+ return { id: rows[0]!.id };
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Lifecycle: soft delete and restore (P7)
450
+ // ---------------------------------------------------------------------------
451
+ //
452
+ // WHY THESE ARE CONTROL-PLANE OPERATIONS AND TAKE NO `Principal`.
453
+ //
454
+ // It is tempting to require `owner` in the org to delete it. That design has
455
+ // a trap in it: deleting an org kills membership-derived access (that is the
456
+ // whole point), so the moment it succeeds NOBODY holds a role in that org and
457
+ // therefore nobody can ever restore it. An authorization rule that makes its
458
+ // own inverse unreachable is not a rule, it is a one-way door.
459
+ //
460
+ // So org and actor lifecycle sits where org and actor CREATION already sits:
461
+ // the control plane, authenticated by the customer's project credential at
462
+ // the API boundary rather than by an end-user principal inside the model.
463
+ // That boundary sits above the engine and is the same one that
464
+ // authenticates every other request. This is stated as an explicit
465
+ // operational requirement in SEMANTICS.md rather than left implicit.
466
+ //
467
+ // Every one of them is audited to the affected tenant's chain, so a
468
+ // control-plane action is as visible in the compliance record as a user one.
469
+
470
+ /**
471
+ * Soft-delete a tenant. Every grant on every file in it is dead on the next
472
+ * request; every membership stops conferring anything. Nothing is erased and
473
+ * no row a retention hold protects is touched, so this cannot be used to
474
+ * defeat retention -- see SEMANTICS.md.
475
+ */
476
+ async softDeleteOrg(orgId: string): Promise<void> {
477
+ await this.#setOrgDeleted(orgId, true);
478
+ }
479
+
480
+ /** Exactly reverses `softDeleteOrg`. Liveness is derived, so nothing is lost. */
481
+ async restoreOrg(orgId: string): Promise<void> {
482
+ await this.#setOrgDeleted(orgId, false);
483
+ }
484
+
485
+ async #setOrgDeleted(orgId: string, deleted: boolean): Promise<void> {
486
+ if (!isUuid(orgId)) throw new FilelayerError(404, 'not_found');
487
+ const { rows } = await this.db.query<{ id: string }>(
488
+ `UPDATE org SET deleted_at = ${deleted ? 'now()' : 'NULL'}
489
+ WHERE id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid)
490
+ RETURNING id`,
491
+ [orgId, this.projectId],
492
+ );
493
+ if (!rows[0]) throw new FilelayerError(404, 'not_found');
494
+ await this.store.audit({
495
+ orgId,
496
+ action: deleted ? 'org.delete' : 'org.restore',
497
+ decision: 'allow',
498
+ actorId: null,
499
+ fileId: null,
500
+ context: { via: 'control_plane' },
501
+ });
502
+ }
503
+
504
+ /**
505
+ * Soft-delete an identity.
506
+ *
507
+ * Three things die at once, and all three are derived rather than written:
508
+ * their role-derived access, every grant issued TO them, and every grant they
509
+ * ISSUED. The third is the judgement call; the reasoning is in schema.sql at
510
+ * `grant_scope_is_live` and in SEMANTICS.md. It is loud on purpose: deleting
511
+ * a prolific sharer revokes a lot of links, and that is the correct reading of
512
+ * P4, not a side effect.
513
+ */
514
+ async softDeleteActor(actorId: string): Promise<void> {
515
+ await this.#setActorDeleted(actorId, true);
516
+ }
517
+
518
+ async restoreActor(actorId: string): Promise<void> {
519
+ await this.#setActorDeleted(actorId, false);
520
+ }
521
+
522
+ async #setActorDeleted(actorId: string, deleted: boolean): Promise<void> {
523
+ if (!isUuid(actorId)) throw new FilelayerError(404, 'not_found');
524
+ const { rows } = await this.db.query<{ id: string }>(
525
+ `UPDATE actor SET deleted_at = ${deleted ? 'now()' : 'NULL'}
526
+ WHERE id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid)
527
+ RETURNING id`,
528
+ [actorId, this.projectId],
529
+ );
530
+ if (!rows[0]) throw new FilelayerError(404, 'not_found');
531
+ // Attributed to every org the identity is a member of: "who lost access
532
+ // here, and when" must be answerable from each affected tenant's own chain.
533
+ const { rows: orgs } = await this.db.query<{ org_id: string }>(
534
+ `SELECT org_id FROM membership WHERE actor_id = $1`,
535
+ [actorId],
536
+ );
537
+ for (const o of orgs) {
538
+ await this.store.audit({
539
+ orgId: o.org_id,
540
+ action: deleted ? 'actor.delete' : 'actor.restore',
541
+ decision: 'allow',
542
+ actorId,
543
+ fileId: null,
544
+ context: { via: 'control_plane', targetActorId: actorId },
545
+ });
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Soft-delete a customer application: every org, every file and every grant
551
+ * inside it stops working immediately. This is the "we terminated that
552
+ * customer" operation and it is the widest blast radius in the system.
553
+ */
554
+ async softDeleteProject(projectId: string): Promise<void> {
555
+ await this.#setProjectDeleted(projectId, true);
556
+ }
557
+
558
+ async restoreProject(projectId: string): Promise<void> {
559
+ await this.#setProjectDeleted(projectId, false);
560
+ }
561
+
562
+ async #setProjectDeleted(projectId: string, deleted: boolean): Promise<void> {
563
+ if (!isUuid(projectId)) throw new FilelayerError(404, 'not_found');
564
+ const { rows } = await this.db.query<{ id: string }>(
565
+ `UPDATE project SET deleted_at = ${deleted ? 'now()' : 'NULL'}
566
+ WHERE id = $1 RETURNING id`,
567
+ [projectId],
568
+ );
569
+ if (!rows[0]) throw new FilelayerError(404, 'not_found');
570
+ // The system chain: a project is above every tenant, so there is no single
571
+ // tenant to charge the event to, and writing it to all of them would let a
572
+ // control-plane action inflate an arbitrary number of customer chains.
573
+ await this.store.audit({
574
+ orgId: null,
575
+ action: deleted ? 'project.delete' : 'project.restore',
576
+ decision: 'allow',
577
+ actorId: null,
578
+ fileId: null,
579
+ context: { chain: 'system', projectId, via: 'control_plane' },
580
+ });
581
+ }
582
+
583
+ /**
584
+ * Add a member, or change an existing member's role.
585
+ *
586
+ * This used to take no principal at all. Anyone who could reach it could
587
+ * make anyone an owner of any org, and nothing was written to the audit log.
588
+ * Membership is the privilege that confers every other privilege, so it is
589
+ * now authorized by the same engine as everything else and audited on every
590
+ * outcome.
591
+ */
592
+ async addMember(
593
+ principal: Principal,
594
+ orgId: string,
595
+ actorId: string,
596
+ role: OrgRole,
597
+ ): Promise<void> {
598
+ const decision = await authorizeMembershipChange(this.store, principal, orgId, actorId, role);
599
+ this.#raise(decision);
600
+ await this.db.query(
601
+ `INSERT INTO membership (org_id, actor_id, role) VALUES ($1,$2,$3)
602
+ ON CONFLICT (org_id, actor_id) DO UPDATE SET role = EXCLUDED.role`,
603
+ [orgId, actorId, role],
604
+ );
605
+ }
606
+
607
+ async removeMember(principal: Principal, orgId: string, actorId: string): Promise<void> {
608
+ const decision = await authorizeMembershipChange(this.store, principal, orgId, actorId, null);
609
+ this.#raise(decision);
610
+ await this.db.query(`DELETE FROM membership WHERE org_id = $1 AND actor_id = $2`, [
611
+ orgId,
612
+ actorId,
613
+ ]);
614
+ }
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // Files
618
+ // ---------------------------------------------------------------------------
619
+
620
+ /**
621
+ * Creation is the one file operation with no file to authorize against, so
622
+ * the question is org-scoped: does this actor hold `create_file` in this org?
623
+ * That is now asked of the engine rather than answered here.
624
+ *
625
+ * SIGNATURE CHANGE. This used to take a bare
626
+ * `actorId: string` while every other method took a `Principal`. That
627
+ * inconsistency was itself the defect: at the one call site in the example the
628
+ * developer passed `b.uploaderId` -- a value out of the REQUEST BODY -- rather
629
+ * than the authenticated actor, because the parameter's type did not tell them
630
+ * which one it wanted. Files are private-by-default AND owner-readable, so
631
+ * forging `owner_id` hands the wrong person permanent read access to the
632
+ * document, silently. A `Principal` is not confusable with a request field.
633
+ */
634
+ async upload(principal: Principal, orgId: string, input: UploadInput): Promise<FileRecord> {
635
+ const actorId = principal.actorId;
636
+ // DELIBERATELY OUTSIDE THE TRANSACTION, and the reason is the storage write
637
+ // that has to happen between this and the INSERT.
638
+ //
639
+ // With `emitAllow: false` this call writes AT MOST ONE STATEMENT: an audit
640
+ // event on the deny path, which `audit_append()` already makes atomic on its
641
+ // own. There is no mutation for it to be atomic *with*. Wrapping it would
642
+ // mean either holding a database connection open across the whole object
643
+ // upload -- minutes, for a large file, on a pooled connection -- or opening a
644
+ // second transaction anyway. The allow event is emitted below, inside the
645
+ // transaction, carrying the file id.
646
+ const decision = await authorizeOrg(this.store, principal, orgId, 'create_file', {
647
+ action: 'file.create',
648
+ emitAllow: false, // the allow event is emitted below, with the file id on it
649
+ });
650
+ this.#raise(decision);
651
+ // `authorizeOrg` denies an anonymous principal before we get here, so the
652
+ // uploader is known. The engine is the thing that established that, which
653
+ // is the point: ownership is derived from the authorized identity and can
654
+ // no longer be supplied alongside it.
655
+ const uploaderId = actorId!;
656
+
657
+ const id = randomUUID();
658
+ const storageKey = `${orgId}/${id}`;
659
+ const now = Date.now();
660
+ const expiresAt = input.expiresIn ? new Date(now + input.expiresIn * 1000) : null;
661
+ const retainUntil = input.retainFor ? new Date(now + input.retainFor * 1000) : null;
662
+ const visibility: FileVisibility = input.visibility ?? 'private';
663
+
664
+ // ORDERING: BYTES FIRST, METADATA SECOND. See the long note in db.ts.
665
+ //
666
+ // The storage write cannot join the transaction, so one of the two possible
667
+ // orderings has to lose. Committing metadata first and crashing would leave
668
+ // a 'ready' file whose object does not exist -- permanent, customer-visible
669
+ // data loss on a row the customer can see in a listing. Writing bytes first
670
+ // and crashing leaves an object no row points at: unreachable (the key is a
671
+ // fresh UUID, never reissued, and every read path starts from a `file` row)
672
+ // and therefore purely a storage cost. That is the cheaper failure and it is
673
+ // the one we take. `collectStorageOrphans()` cleans up; running it is a
674
+ // required operational job, not an optional one.
675
+ //
676
+ // It also means the adapter, not the caller, reports how many bytes exist.
677
+ const put = await this.storage.put(storageKey, input.body, input.contentType, {
678
+ ...(input.size !== undefined ? { contentLength: input.size } : {}),
679
+ });
680
+
681
+ return this.#transaction(async (tx, store) => {
682
+ const { rows } = await tx.query<Record<string, unknown>>(
683
+ `INSERT INTO file
684
+ (id, org_id, owner_id, name, content_type, size_bytes, storage_provider,
685
+ storage_key, state, visibility, expires_at, retain_until, metadata)
686
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'ready',$9,$10,$11,$12::jsonb)
687
+ RETURNING id, org_id, owner_id, name, content_type, size_bytes,
688
+ storage_provider, storage_key, state, visibility, expires_at,
689
+ retain_until, created_at`,
690
+ [
691
+ id,
692
+ orgId,
693
+ uploaderId,
694
+ input.name,
695
+ input.contentType,
696
+ // What was actually written, not what the caller claimed.
697
+ put.bytes,
698
+ // THE FIX. This was the literal 'memory'.
699
+ this.storage.provider,
700
+ storageKey,
701
+ visibility,
702
+ expiresAt?.toISOString() ?? null,
703
+ retainUntil?.toISOString() ?? null,
704
+ JSON.stringify(input.metadata ?? {}),
705
+ ],
706
+ );
707
+
708
+ // Same transaction as the INSERT it describes. Before this, a failure
709
+ // between the two produced a file with no audit record -- in a product
710
+ // whose headline is a tamper-evident audit trail, an intact chain that
711
+ // simply does not mention the upload.
712
+ await store.audit({
713
+ orgId,
714
+ action: 'file.create',
715
+ decision: 'allow',
716
+ actorId: uploaderId,
717
+ fileId: id,
718
+ context: { visibility, storageProvider: this.storage.provider },
719
+ });
720
+ await store.recordUsage(orgId, 'write', put.bytes);
721
+ // Metering counts distinct FILE-OWNING USERS as well as bytes, because
722
+ // authorization load tracks people rather than volume. The write path is
723
+ // the only place a new owner can appear, so it is the only place this can
724
+ // be recorded.
725
+ await store.recordFileOwner(orgId, uploaderId);
726
+ return toFileRecord(rows[0]!);
727
+ });
728
+ }
729
+
730
+ /**
731
+ * Read a file's bytes, WITH the headers required to serve them safely.
732
+ *
733
+ * The `headers` field closes a header-handling defect. Before it, this method returned a
734
+ * `Uint8Array` and the application decided what `Content-Type`,
735
+ * `Content-Disposition`, `X-Content-Type-Options` and `Cache-Control` to put
736
+ * on the response -- three security-sensitive decisions the library was
737
+ * handing back to the developer while claiming to have removed them, and
738
+ * which the example got wrong. They are computed here now, from the file
739
+ * record, with no option to disable them. See `delivery.ts`.
740
+ *
741
+ * This path now CHARGES the download cap. See `deliver()` below for the
742
+ * semantics and the reasoning.
743
+ */
744
+ async read(
745
+ principal: Principal,
746
+ fileId: string,
747
+ opts: { disposition?: Disposition } = {},
748
+ ): Promise<{
749
+ file: FileRecord;
750
+ body: Uint8Array;
751
+ headers: Record<string, string>;
752
+ grantId?: string;
753
+ /** Null when no cap binds this delivery (a role-derived read, or no cap). */
754
+ remainingDownloads: number | null;
755
+ }> {
756
+ // The buffered convenience form. It is `readStream()` plus a collect, so
757
+ // there is exactly one authorization path, one reservation and one audit
758
+ // event whichever form a caller uses. It FORCES proxy mode: a buffered read
759
+ // of a redirect is a contradiction, and silently fetching the presigned URL
760
+ // ourselves would spend the redirect's egress budget AND the proxy's.
761
+ const d = await this.readStream(principal, fileId, { ...opts, mode: 'proxy' });
762
+ if (d.mode !== 'proxy') throw new FilelayerError(500, 'internal', 'unexpected_redirect');
763
+ const body = await collectStream(d.body);
764
+ return {
765
+ file: d.file,
766
+ body,
767
+ headers: d.headers,
768
+ ...(d.grantId ? { grantId: d.grantId } : {}),
769
+ remainingDownloads: d.remainingDownloads,
770
+ };
771
+ }
772
+
773
+ /**
774
+ * The streaming read. Same decision, same charge, same audit -- no buffer.
775
+ *
776
+ * Returns either a `ProxyDelivery` (bytes, as a stream) or, when redirect
777
+ * delivery is configured AND this delivery is eligible, a `RedirectDelivery`
778
+ * (a 302 to a short-lived presigned URL). The mode is on the returned object
779
+ * and in the audit log; nothing about it is implicit.
780
+ */
781
+ async readStream(
782
+ principal: Principal,
783
+ fileId: string,
784
+ /**
785
+ * `mode` defaults to 'auto', which means "apply this instance's redirect
786
+ * policy". On an instance that has not configured `redirectDelivery` -- the
787
+ * default -- that policy is "never redirect", so 'auto' and 'proxy' are the
788
+ * same thing and nothing becomes cacheable that was not before. Pass
789
+ * 'proxy' to force proxying on an instance that HAS opted in.
790
+ */
791
+ opts: { disposition?: Disposition; mode?: 'proxy' | 'auto'; range?: { start: number; end?: number } } = {},
792
+ ): Promise<StreamedDelivery & { file: FileRecord; grantId?: string; remainingDownloads: number | null }> {
793
+ // THE DECISION AND THE CHARGE, IN ONE TRANSACTION.
794
+ //
795
+ // `authorize()` writes the access event and `consumeDownload()` spends the
796
+ // cap. Those two were separate autocommit statements, so a crash between
797
+ // them left an allow event for a delivery that was never charged, or -- on
798
+ // the redeem path -- a charge with no event. They now commit together.
799
+ const reserved = await this.#transaction(async (tx, store) => {
800
+ const decision = await authorize(store, principal, fileId, 'read');
801
+ this.#raise(decision);
802
+ return this.#reserve(tx, store, fileId, decision, principal, opts);
803
+ });
804
+
805
+ return this.#fetchDelivery(reserved, opts);
806
+ }
807
+
808
+ /**
809
+ * Metadata without bytes, authorized exactly like `read`.
810
+ *
811
+ * This exists because `getFileRecord()` used to be public and took no
812
+ * principal -- see the note on it below. Callers that wanted a file's
813
+ * metadata had an unauthorized way to get it; now they have an authorized one.
814
+ *
815
+ * It does NOT charge the download cap, and that asymmetry is the whole point
816
+ * of the delivery-cap rule: the cap counts BYTES LEAVING, and `stat` delivers
817
+ * none.
818
+ */
819
+ async stat(principal: Principal, fileId: string): Promise<FileRecord> {
820
+ return this.#transaction(async (tx, store) => {
821
+ const decision = await authorize(store, principal, fileId, 'read');
822
+ this.#raise(decision);
823
+ const file = await getFileRecord(tx, this.projectId, fileId);
824
+ if (!file) throw new FilelayerError(404, 'not_found');
825
+ return file;
826
+ });
827
+ }
828
+
829
+ /**
830
+ * THE ONE PLACE BYTES LEAVE THE SYSTEM -- and therefore the one place the
831
+ * download cap is charged (P6).
832
+ *
833
+ * THE DEFECT. `max_downloads` was charged only by `redeem()`, the share-link
834
+ * path. An ACTOR grant carrying `maxDownloads: 3` permitted unlimited direct
835
+ * `read()` calls, because nothing on that path touched the counter. So the
836
+ * field meant "link redemptions" on one path and "nothing at all" on another,
837
+ * while being named, documented and billed as a download cap. In practice the
838
+ * direct path is the COMMON one -- the SDK calls `read()` --
839
+ * so the dimension was a lie on the path most customers use.
840
+ *
841
+ * THE DECISION, of the three that were on the table:
842
+ *
843
+ * (a) rename it `maxRedemptions`. Rejected: it would still be settable on
844
+ * an actor grant, where it would then mean nothing, so the ambiguity
845
+ * moves rather than closes.
846
+ * (b) refuse `maxDownloads` on non-link grants. Rejected: "you may read
847
+ * this three times" is a thing customers legitimately want to say about
848
+ * a named person, and refusing it removes a capability to avoid
849
+ * defining one.
850
+ * (c) CHARGE ON EVERY DELIVERY. Chosen. A cap of 3 means the bytes leave at
851
+ * most 3 times, through any path, by any principal, at any delegation
852
+ * depth. It is the reading a customer already has, it is the only one
853
+ * that is true on every path, and it makes the cap enforceable rather
854
+ * than advisory.
855
+ *
856
+ * THE RULE, precisely: a delivery is charged when, and only when, the
857
+ * authorization decision was reached VIA A GRANT. Authority from an org role
858
+ * is not a metered credential and is not charged -- an admin doing their job
859
+ * must not silently burn a contractor's link budget. `authorize()` alone does
860
+ * not charge (it is a decision, not a delivery) and neither does `stat()`.
861
+ *
862
+ * ORDERING: reserve BEFORE fetching bytes, exactly as `redeem()` does. The
863
+ * reservation is the write (P6), so two concurrent deliveries against a cap
864
+ * of 1 yield one delivery; doing it the other way round would let both read
865
+ * the object and only then discover one of them was over budget. A storage
866
+ * failure after a successful reservation therefore still spends a download.
867
+ * That is the fail-closed direction and it is deliberate.
868
+ *
869
+ * KNOWN COST, recorded rather than hidden. `consume_download` runs even
870
+ * when no grant in the chain carries a cap, because `download_count` is also
871
+ * the answer to "how many times has this link been downloaded", which
872
+ * `listGrants` reports and a compliance screen asks for. That makes every
873
+ * grant-authorized delivery a row UPDATE holding a row lock -- and for a
874
+ * TIER-1 PUBLIC ASSET, where one anonymous grant row serves every request,
875
+ * that single row becomes a write hotspot under load. It is a scalability
876
+ * problem, not a correctness one, and the fix (skip the write when no
877
+ * ancestor has a cap, and meter deliveries elsewhere) trades away the
878
+ * per-grant download count. Not taken here because that count is a shipped
879
+ * feature; flagged so the trade is made deliberately when volume forces it.
880
+ */
881
+ async #reserve(
882
+ tx: Tx,
883
+ store: PostgresStore,
884
+ fileId: string,
885
+ decision: Extract<Decision, { allow: true }>,
886
+ principal: Principal,
887
+ opts: { disposition?: Disposition; mode?: 'proxy' | 'auto' },
888
+ ): Promise<Reservation> {
889
+ const grantId = decision.grantId ?? null;
890
+ let remainingDownloads: number | null = null;
891
+
892
+ if (grantId !== null) {
893
+ const consumed = await store.consumeDownload(grantId);
894
+ if (!consumed.granted) {
895
+ // Reachable only when the cap is hit between the decision and the
896
+ // reservation. The engine records the ordinary case; this records the
897
+ // race, so the two cannot silently become one.
898
+ const file = await getFileRecord(tx, this.projectId, fileId);
899
+ await store.audit({
900
+ orgId: file?.orgId ?? null,
901
+ action: 'file.read',
902
+ decision: 'deny',
903
+ reason: 'grant_exhausted',
904
+ actorId: principal.actorId,
905
+ fileId,
906
+ grantId,
907
+ ...(principal.ip !== undefined ? { ip: principal.ip } : {}),
908
+ context: { race: true, ...(file ? {} : { chain: 'system' }) },
909
+ });
910
+ throw new FilelayerError(404, 'not_found', 'grant_exhausted');
911
+ }
912
+ remainingDownloads = consumed.remaining;
913
+ }
914
+
915
+ const file = await getFileRecord(tx, this.projectId, fileId);
916
+ if (!file) throw new FilelayerError(404, 'not_found');
917
+
918
+ const headers = deliveryHeaders(file, opts);
919
+ const mode = this.#redirectEligible(decision, opts.mode ?? 'auto');
920
+
921
+ if (mode === 'proxy') {
922
+ return { kind: 'proxy', file, headers, remainingDownloads, grantId };
923
+ }
924
+
925
+ // The presigned URL is minted INSIDE the transaction, before the audit
926
+ // event that records it. `presignGet` for the S3 adapter is local HMAC with
927
+ // no I/O; an adapter for which that is not true must still keep it cheap,
928
+ // because it sits inside an open transaction. Minting first means we never
929
+ // audit a redirect we then failed to produce.
930
+ const redirect = this.redirect!;
931
+ const url = await (this.storage as Required<Pick<StorageAdapter, 'presignGet'>>).presignGet(
932
+ file.storageKey,
933
+ {
934
+ expiresInSeconds: redirect.ttlSeconds,
935
+ // Pin the SAME neutralised type and disposition the proxied path would
936
+ // have sent, so a redirect cannot be a way to lose them.
937
+ responseContentType: headers['content-type']!,
938
+ responseContentDisposition: headers['content-disposition']!,
939
+ },
940
+ );
941
+ const expiresAt = new Date(Date.now() + redirect.ttlSeconds * 1000);
942
+
943
+ // THE EVENT THAT MAKES THE MODE AUDITABLE. Written only for redirects, in
944
+ // the same transaction as the reservation. A compliance auditor asking "which
945
+ // deliveries left our control?" filters `action = 'file.deliver'`; every
946
+ // other delivery was proxied.
947
+ await store.audit({
948
+ orgId: file.orgId,
949
+ action: 'file.deliver',
950
+ decision: 'allow',
951
+ actorId: principal.actorId,
952
+ fileId,
953
+ grantId,
954
+ ...(principal.ip !== undefined ? { ip: principal.ip } : {}),
955
+ ...(principal.userAgent !== undefined ? { userAgent: principal.userAgent } : {}),
956
+ context: {
957
+ mode: 'redirect',
958
+ via: decision.via,
959
+ ttlSeconds: redirect.ttlSeconds,
960
+ // The number a compliance document quotes. Spelled out rather than
961
+ // derived, so it survives a change to how the TTL is computed.
962
+ revocationWindowSeconds: redirect.ttlSeconds,
963
+ expiresAt: expiresAt.toISOString(),
964
+ cacheable: decision.via === 'grant:anonymous',
965
+ },
966
+ });
967
+
968
+ return {
969
+ kind: 'redirect',
970
+ file,
971
+ headers,
972
+ remainingDownloads,
973
+ grantId,
974
+ url,
975
+ expiresAt,
976
+ ttlSeconds: redirect.ttlSeconds,
977
+ cacheable: decision.via === 'grant:anonymous',
978
+ };
979
+ }
980
+
981
+ /**
982
+ * Is this delivery allowed to be a redirect?
983
+ *
984
+ * Four conditions, all required, and the default answer is no:
985
+ * 1. the caller asked for 'auto' (routes default to 'proxy');
986
+ * 2. redirect delivery is configured -- which required the acknowledgement;
987
+ * 3. the adapter can actually mint a presigned URL;
988
+ * 4. the authority came from an ANONYMOUS grant, unless the scope was
989
+ * explicitly widened to 'all-grants'.
990
+ *
991
+ * Condition 4 is the one that matters. `via` is the engine's own account of
992
+ * where the authority came from, so "public" here means "the customer
993
+ * published this file", not "the request looked public".
994
+ */
995
+ #redirectEligible(
996
+ decision: Extract<Decision, { allow: true }>,
997
+ requested: 'proxy' | 'auto',
998
+ ): 'proxy' | 'redirect' {
999
+ if (requested !== 'auto') return 'proxy';
1000
+ if (this.redirect === null) return 'proxy';
1001
+ if (!canPresign(this.storage)) return 'proxy';
1002
+ if (this.redirect.scope === 'anonymous-grants-only' && decision.via !== 'grant:anonymous') {
1003
+ return 'proxy';
1004
+ }
1005
+ return 'redirect';
1006
+ }
1007
+
1008
+ /**
1009
+ * Turn a committed reservation into bytes (or a 302).
1010
+ *
1011
+ * Deliberately OUTSIDE the transaction. Fetching an object can take minutes;
1012
+ * holding a database connection open for that is how a pool dies. It also
1013
+ * preserves the documented P6 property that a storage failure after a
1014
+ * successful reservation still spends a download -- the reservation is
1015
+ * already committed, so nothing can give it back.
1016
+ */
1017
+ async #fetchDelivery(
1018
+ r: Reservation,
1019
+ opts: { range?: { start: number; end?: number } },
1020
+ ): Promise<StreamedDelivery & { file: FileRecord; grantId?: string; remainingDownloads: number | null }> {
1021
+ if (r.kind === 'redirect') {
1022
+ const d: RedirectDelivery & {
1023
+ file: FileRecord;
1024
+ grantId?: string;
1025
+ remainingDownloads: number | null;
1026
+ } = {
1027
+ mode: 'redirect',
1028
+ file: r.file,
1029
+ status: 302,
1030
+ url: r.url,
1031
+ expiresAt: r.expiresAt,
1032
+ revocationWindowSeconds: r.ttlSeconds,
1033
+ headers: redirectHeaders(r.url, { ttlSeconds: r.ttlSeconds, cacheable: r.cacheable }),
1034
+ remainingDownloads: r.remainingDownloads,
1035
+ ...(r.grantId ? { grantId: r.grantId } : {}),
1036
+ };
1037
+ return d;
1038
+ }
1039
+
1040
+ const obj: ObjectStream | null = await this.storage.stream(r.file.storageKey, {
1041
+ ...(opts.range ? { range: opts.range } : {}),
1042
+ });
1043
+ if (!obj) throw new FilelayerError(404, 'not_found');
1044
+
1045
+ // Metering is deliberately outside the transaction and best-effort: it is
1046
+ // not a decision, and a metering failure must not fail a delivery the
1047
+ // system already authorized, charged and audited.
1048
+ const bytes = obj.size ?? r.file.sizeBytes ?? 0;
1049
+ await this.store.recordUsage(r.file.orgId, 'read', bytes).catch(() => {});
1050
+
1051
+ const headers = { ...r.headers };
1052
+ // Trust the store's length over the column: a `size_bytes` that disagrees
1053
+ // with the object truncates or hangs the response.
1054
+ if (obj.size !== null) headers['content-length'] = String(obj.size);
1055
+ else delete headers['content-length'];
1056
+ if (obj.range) {
1057
+ headers['content-range'] = `bytes ${obj.range.start}-${obj.range.end}/${obj.range.total}`;
1058
+ headers['accept-ranges'] = 'bytes';
1059
+ }
1060
+
1061
+ const d: ProxyDelivery & {
1062
+ file: FileRecord;
1063
+ grantId?: string;
1064
+ remainingDownloads: number | null;
1065
+ } = {
1066
+ mode: 'proxy',
1067
+ file: r.file,
1068
+ headers,
1069
+ body: obj.body,
1070
+ bytes: obj.size,
1071
+ remainingDownloads: r.remainingDownloads,
1072
+ ...(r.grantId ? { grantId: r.grantId } : {}),
1073
+ };
1074
+ return d;
1075
+ }
1076
+
1077
+ // ---------------------------------------------------------------------------
1078
+ // Listing -- the authorized query surface
1079
+ // ---------------------------------------------------------------------------
1080
+
1081
+ /**
1082
+ * Which files in this org may this principal `capability`?
1083
+ *
1084
+ * This is the primitive that was missing, and its absence was
1085
+ * the reason the "0 authorization lines" claim survived: the example simply
1086
+ * did not have a listing screen, and building one meant hand-rolling the org
1087
+ * filter, the visibility rule, the owner check, the role check and the union
1088
+ * over `file_grant` in application SQL.
1089
+ *
1090
+ * WHY IT CANNOT BE GOT WRONG.
1091
+ *
1092
+ * - There is no filter parameter. The signature takes a principal, an org,
1093
+ * a capability, a page size and an opaque cursor. There is nothing here to
1094
+ * forget to pass and nothing that widens the result set.
1095
+ * - The predicate is generated from the same role table and the same
1096
+ * lifecycle gate `authorize()` uses (see `listPredicate` in authz.ts), so
1097
+ * the two cannot drift by editing one of them.
1098
+ * - `test/listing.test.ts` asserts set equality against `authorize()` over a
1099
+ * randomized corpus, on every capability, on every run.
1100
+ *
1101
+ * WHAT IT COSTS. One SQL query and one audit event, independent of page size.
1102
+ * A per-file `authorize()` loop would be 4 round trips x N.
1103
+ *
1104
+ * The empty page is a valid answer: a caller with no standing sees nothing,
1105
+ * and so does a caller naming an org that does not exist. Neither is an error,
1106
+ * because distinguishing them would rebuild the existence oracle.
1107
+ */
1108
+ async listFiles(
1109
+ principal: Principal,
1110
+ orgId: string,
1111
+ opts: ListFilesOptions = {},
1112
+ ): Promise<FileListPage> {
1113
+ // A link secret is a bearer credential for exactly one file. Refused rather
1114
+ // than ignored: a silently-dropped credential is how a caller ends up
1115
+ // believing they listed something they did not.
1116
+ if (principal.linkSecret !== undefined) {
1117
+ throw new FilelayerError(400, 'link_principal_cannot_list', 'link_principal_cannot_list');
1118
+ }
1119
+ const capability = opts.capability ?? 'read';
1120
+ const limit = Math.max(1, Math.min(opts.limit ?? LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT));
1121
+
1122
+ const { files, hasMore } = await authorizeList(this.store, principal, orgId, {
1123
+ capability,
1124
+ limit,
1125
+ cursor: decodeCursor(opts.cursor),
1126
+ });
1127
+
1128
+ const last = files[files.length - 1];
1129
+ return {
1130
+ // `ListedFile` and `FileRecord` are the same shape; the store returns the
1131
+ // columns `getFileRecord` returns, so nothing is re-fetched per row.
1132
+ files: files as FileRecord[],
1133
+ nextCursor: hasMore && last ? encodeCursor(last.createdAt, last.id) : null,
1134
+ };
1135
+ }
1136
+
1137
+ /**
1138
+ * ORDERING, and it is the mirror image of `upload()`.
1139
+ *
1140
+ * The metadata delete COMMITS FIRST -- the decision, the audit event the
1141
+ * engine wrote for it, and the state change, all in one transaction -- and
1142
+ * only then are the bytes removed. A crash in between leaves an object no row
1143
+ * points at, which is an orphan and therefore a garbage-collection problem.
1144
+ * The other ordering would leave a live, listable, authorizable `file` row
1145
+ * whose object is gone, which is data loss.
1146
+ *
1147
+ * The bytes are removed OUTSIDE the transaction for the same reason they are
1148
+ * written outside it: object storage cannot roll back, so including it would
1149
+ * mean a rolled-back transaction had already destroyed the object.
1150
+ */
1151
+ async delete(principal: Principal, fileId: string): Promise<void> {
1152
+ const file = await this.#transaction(async (tx, store) => {
1153
+ const decision = await authorize(store, principal, fileId, 'delete');
1154
+ this.#raise(decision);
1155
+
1156
+ const f = await getFileRecord(tx, this.projectId, fileId);
1157
+ if (!f) throw new FilelayerError(404, 'not_found');
1158
+ await tx.query(
1159
+ `UPDATE file SET state = 'deleted', deleted_at = now(), updated_at = now()
1160
+ WHERE id = $1`,
1161
+ [fileId],
1162
+ );
1163
+ return f;
1164
+ });
1165
+ await this.storage.delete(file.storageKey);
1166
+ }
1167
+
1168
+ /**
1169
+ * COLLECT ORPHANED OBJECTS. A REQUIRED OPERATIONAL JOB.
1170
+ *
1171
+ * An orphan is an object in the store with no `file` row pointing at
1172
+ * (provider, key). Two things produce them, both of them by design:
1173
+ *
1174
+ * - a crash between `storage.put()` and the metadata commit in `upload()`;
1175
+ * - a crash between the metadata commit and `storage.delete()` in
1176
+ * `delete()`.
1177
+ *
1178
+ * Neither is a correctness problem -- an orphan is unreachable, because every
1179
+ * read path in the system starts from a `file` row, and keys are fresh UUIDs
1180
+ * that are never reissued -- but both cost money, and an uncollected orphan
1181
+ * from a delete is a compliance problem: the customer was told the bytes were
1182
+ * gone.
1183
+ *
1184
+ * WHAT MAKES THIS SAFE. Two things, and they are both load-bearing:
1185
+ *
1186
+ * 1. `olderThanSeconds` (default 1 hour, minimum 60s). An object written
1187
+ * seconds ago may belong to an upload whose transaction has not committed
1188
+ * yet. Deleting it would turn a successful upload into permanent data
1189
+ * loss -- the exact failure this whole ordering exists to avoid. The grace
1190
+ * period must exceed the longest plausible upload-plus-commit.
1191
+ * 2. The `file` lookup is by (storage_provider, storage_key), the pair the
1192
+ * UNIQUE index is on, and it is NOT project-scoped and NOT filtered on
1193
+ * `deleted_at`. A soft-deleted file whose bytes were never removed still
1194
+ * has a row; this job must not race the delete path into removing bytes a
1195
+ * retention hold is protecting. It only removes what NOTHING references.
1196
+ *
1197
+ * Control plane: it takes no principal for the same reason the other
1198
+ * lifecycle operations do not (see above). `dryRun` is the default.
1199
+ */
1200
+ async collectStorageOrphans(
1201
+ opts: {
1202
+ prefix?: string;
1203
+ olderThanSeconds?: number;
1204
+ limit?: number;
1205
+ dryRun?: boolean;
1206
+ } = {},
1207
+ ): Promise<{ scanned: number; orphans: string[]; deleted: number; truncated: boolean }> {
1208
+ if (!canList(this.storage)) {
1209
+ throw new FilelayerError(
1210
+ 500,
1211
+ 'storage_cannot_list',
1212
+ 'orphan collection needs a storage adapter that implements list()',
1213
+ );
1214
+ }
1215
+ const grace = Math.max(60, opts.olderThanSeconds ?? 3600) * 1000;
1216
+ const limit = Math.max(1, Math.min(opts.limit ?? 1000, 10_000));
1217
+ const cutoff = Date.now() - grace;
1218
+ const dryRun = opts.dryRun ?? true;
1219
+
1220
+ let cursor: string | null = null;
1221
+ let scanned = 0;
1222
+ const orphans: string[] = [];
1223
+ let truncated = false;
1224
+
1225
+ do {
1226
+ const page: { entries: Array<{ key: string; lastModified: Date | null }>; cursor: string | null } =
1227
+ await this.storage.list(opts.prefix ?? '', {
1228
+ limit: Math.min(1000, limit),
1229
+ cursor,
1230
+ });
1231
+ cursor = page.cursor;
1232
+ for (const e of page.entries) {
1233
+ scanned++;
1234
+ // No timestamp means we cannot prove it is old. Fail closed: skip it.
1235
+ if (e.lastModified === null || e.lastModified.getTime() > cutoff) continue;
1236
+ const { rows } = await this.db.query(
1237
+ `SELECT 1 FROM file WHERE storage_provider = $1 AND storage_key = $2`,
1238
+ [this.storage.provider, e.key],
1239
+ );
1240
+ if (rows.length > 0) continue;
1241
+ orphans.push(e.key);
1242
+ if (orphans.length >= limit) {
1243
+ truncated = true;
1244
+ break;
1245
+ }
1246
+ }
1247
+ } while (cursor !== null && !truncated);
1248
+
1249
+ let deleted = 0;
1250
+ if (!dryRun) {
1251
+ for (const key of orphans) {
1252
+ await this.storage.delete(key);
1253
+ deleted++;
1254
+ }
1255
+ await this.store.audit({
1256
+ orgId: null,
1257
+ action: 'storage.gc',
1258
+ decision: 'allow',
1259
+ actorId: null,
1260
+ fileId: null,
1261
+ context: {
1262
+ chain: 'system',
1263
+ provider: this.storage.provider,
1264
+ scanned,
1265
+ deleted,
1266
+ via: 'control_plane',
1267
+ },
1268
+ });
1269
+ }
1270
+ return { scanned, orphans, deleted, truncated };
1271
+ }
1272
+
1273
+ // ---------------------------------------------------------------------------
1274
+ // Grants
1275
+ // ---------------------------------------------------------------------------
1276
+
1277
+ async share(principal: Principal, fileId: string, input: ShareInput): Promise<ShareResult> {
1278
+ const capabilities = input.capabilities ?? (['read'] as Capability[]);
1279
+
1280
+ // ONE TRANSACTION: the decision, the grant row, and the audit event that
1281
+ // records both. Previously these were three autocommit statements, so a
1282
+ // failure in the middle could leave a live grant that the audit log has no
1283
+ // record of anyone creating -- a grant with no provenance, which for a
1284
+ // capability system is the worst possible row to be missing.
1285
+ return this.#transaction(async (tx, store) => {
1286
+ // One call, two questions: may you share, and is what you are handing out a
1287
+ // subset of what you hold? Both are answered by the engine. The
1288
+ // engine also tells us which grant your authority came from, which becomes
1289
+ // this grant's parent and is what makes revocation transitive.
1290
+ // The subject type is part of the authorization question, not a detail of
1291
+ // the row: I6 says a grant-derived issuer may not widen the population.
1292
+ // Passing it here is what lets the ENGINE refuse -- with a reason and an
1293
+ // audit event -- rather than leaving the trigger to raise at INSERT time.
1294
+ const decision = await authorizeShare(store, principal, fileId, capabilities, {
1295
+ subjectType: input.subject.type,
1296
+ });
1297
+ if (!decision.allow) {
1298
+ const pub = toPublicError(decision.reason);
1299
+ throw new FilelayerError(pub.status, pub.code, decision.reason);
1300
+ }
1301
+
1302
+ const file = await getFileRecord(tx, this.projectId, fileId);
1303
+ if (!file) throw new FilelayerError(404, 'not_found');
1304
+
1305
+ const expiresAt = input.expiresIn ? new Date(Date.now() + input.expiresIn * 1000) : null;
1306
+
1307
+ let secret: string | undefined;
1308
+ let secretHash: string | null = null;
1309
+ let subjectId: string | null = null;
1310
+ let subjectOrgId: string | null = null;
1311
+ let subjectMinRole: OrgRole | null = null;
1312
+
1313
+ if (input.subject.type === 'link') {
1314
+ // 256 bits from the CSPRNG. Base64url so it survives a URL path segment.
1315
+ secret = randomBytes(32).toString('base64url');
1316
+ secretHash = await this.store.hashSecret(secret);
1317
+ } else if (input.subject.type === 'actor') {
1318
+ subjectId = input.subject.actorId;
1319
+ } else if (input.subject.type === 'org' || input.subject.type === 'role') {
1320
+ // I1/P8. The composite FK already makes a cross-PROJECT subject org
1321
+ // unrepresentable; this resolves it first so the caller gets the same
1322
+ // uniform 404 they get for any other id they may not name, instead of a
1323
+ // foreign-key violation that would confirm the id exists somewhere. An
1324
+ // org in another project, an org that does not exist, and a malformed id
1325
+ // are one answer -- the same symmetry the file paths keep.
1326
+ subjectOrgId = await this.#resolveSubjectOrg(tx, input.subject.orgId);
1327
+ if (input.subject.type === 'role') subjectMinRole = input.subject.minRole;
1328
+ }
1329
+
1330
+ const passwordHash = input.password ? await this.store.hashPassword(input.password) : null;
1331
+
1332
+ // org_id is taken from the FILE, never from the caller. The composite FK
1333
+ // (file_id, org_id) -> file(id, org_id) makes a mismatch unrepresentable,
1334
+ // but taking it from the caller at all would be an invitation.
1335
+ //
1336
+ // The RETURNING clause reads back expires_at and max_downloads because the
1337
+ // attenuation trigger may have tightened them against the parent grant. We
1338
+ // report what was actually stored, not what was asked for.
1339
+ //
1340
+ // THE SAVEPOINT IS NOT OPTIONAL. A statement that RAISES inside a Postgres
1341
+ // transaction aborts the whole transaction: every subsequent statement
1342
+ // fails with "current transaction is aborted". The attenuation trigger
1343
+ // raising is precisely the case where we must keep going, because the
1344
+ // refusal has to be AUDITED. Without the savepoint the audit write below
1345
+ // would itself fail and the refusal would vanish -- the transaction work
1346
+ // would have silently deleted a security event.
1347
+ let rows: Array<{ id: string; expires_at: string | null; max_downloads: number | null }>;
1348
+ try {
1349
+ ({ rows } = await tx.savepoint(() => tx.query<{
1350
+ id: string;
1351
+ expires_at: string | null;
1352
+ max_downloads: number | null;
1353
+ }>(
1354
+ `INSERT INTO file_grant
1355
+ (file_id, org_id, parent_grant_id, subject_type, subject_id, subject_org_id,
1356
+ subject_min_role, capabilities,
1357
+ secret_hash, password_hash, expires_at, max_downloads, created_by)
1358
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8::grant_capability[],$9,$10,$11,$12,$13)
1359
+ RETURNING id, expires_at, max_downloads`,
1360
+ [
1361
+ fileId,
1362
+ file.orgId,
1363
+ decision.parentGrantId,
1364
+ input.subject.type,
1365
+ subjectId,
1366
+ subjectOrgId,
1367
+ subjectMinRole,
1368
+ pgArrayLiteral(capabilities),
1369
+ secretHash,
1370
+ passwordHash,
1371
+ expiresAt?.toISOString() ?? null,
1372
+ input.maxDownloads ?? null,
1373
+ principal.actorId,
1374
+ ],
1375
+ )));
1376
+ } catch (err) {
1377
+ // The schema is the backstop for attenuation; if it fires, it has caught
1378
+ // something the engine let through.
1379
+ const refusal = schemaRefusal(err);
1380
+ if (!refusal) throw err;
1381
+ await store.audit({
1382
+ orgId: file.orgId,
1383
+ action: 'grant.create',
1384
+ decision: 'deny',
1385
+ reason: refusal,
1386
+ actorId: principal.actorId,
1387
+ fileId,
1388
+ grantId: decision.parentGrantId,
1389
+ context: { capabilities, subjectType: input.subject.type },
1390
+ });
1391
+ throw new FilelayerError(403, 'forbidden', refusal);
1392
+ }
1393
+
1394
+
1395
+ const grantId = rows[0]!.id;
1396
+ const effectiveExpiry = rows[0]!.expires_at ? new Date(rows[0]!.expires_at) : null;
1397
+ const effectiveCap = rows[0]!.max_downloads ?? null;
1398
+
1399
+ await store.audit({
1400
+ orgId: file.orgId,
1401
+ action: 'grant.create',
1402
+ decision: 'allow',
1403
+ actorId: principal.actorId,
1404
+ fileId,
1405
+ grantId,
1406
+ context: {
1407
+ subjectType: input.subject.type,
1408
+ ...(subjectOrgId ? { subjectOrgId } : {}),
1409
+ ...(subjectMinRole ? { subjectMinRole } : {}),
1410
+ capabilities,
1411
+ parentGrantId: decision.parentGrantId,
1412
+ expiresAt: effectiveExpiry?.toISOString() ?? null,
1413
+ maxDownloads: effectiveCap,
1414
+ },
1415
+ });
1416
+
1417
+ return {
1418
+ grantId,
1419
+ ...(secret ? { secret } : {}),
1420
+ ...(secret && this.opts.baseUrl ? { url: `${this.opts.baseUrl}/d/${secret}` } : {}),
1421
+ expiresAt: effectiveExpiry,
1422
+ maxDownloads: effectiveCap,
1423
+ parentGrantId: decision.parentGrantId,
1424
+ };
1425
+ });
1426
+ }
1427
+
1428
+ /**
1429
+ * Resolve the org named by an `org` / `role` grant subject (RFC-001, I1).
1430
+ *
1431
+ * Three things have to be true and only one of them is about convenience:
1432
+ *
1433
+ * - the org must EXIST and not be soft-deleted (a grant naming a dead tenant
1434
+ * would be born non-live anyway -- see `grant_scope_is_live` -- so minting
1435
+ * one is a caller error worth reporting);
1436
+ * - it must be in THIS instance's project. That is the P8 boundary, and it
1437
+ * is the thing that makes a cross-project group grant unrepresentable. The
1438
+ * composite foreign key enforces it regardless; this exists so the answer
1439
+ * is a clean 404 rather than a constraint violation whose message would
1440
+ * itself confirm the id resolves to a row somewhere in the database.
1441
+ * - it need NOT be the file's own org. Cross-ORG group grants inside one
1442
+ * project are the whole point of the feature.
1443
+ */
1444
+ async #resolveSubjectOrg(tx: Tx, orgId: string): Promise<string> {
1445
+ if (!isUuid(orgId)) throw new FilelayerError(404, 'not_found', 'unknown_subject_org');
1446
+ const { rows } = await tx.query<{ id: string }>(
1447
+ `SELECT o.id FROM org o
1448
+ JOIN project p ON p.id = o.project_id
1449
+ WHERE o.id = $1
1450
+ AND o.deleted_at IS NULL
1451
+ AND p.deleted_at IS NULL
1452
+ AND ($2::uuid IS NULL OR o.project_id = $2::uuid)`,
1453
+ [orgId, this.projectId],
1454
+ );
1455
+ if (!rows[0]) throw new FilelayerError(404, 'not_found', 'unknown_subject_org');
1456
+ return rows[0].id;
1457
+ }
1458
+
1459
+ /**
1460
+ * Revoke a grant.
1461
+ *
1462
+ * Nothing cascades, and nothing needs to: liveness is evaluated over the
1463
+ * ancestor chain, so every grant ever delegated from this one dies in the
1464
+ * same instant, at any depth, with no second write to get wrong (P4).
1465
+ */
1466
+ async revoke(principal: Principal, grantId: string): Promise<void> {
1467
+ if (!isUuid(grantId)) throw new FilelayerError(404, 'not_found');
1468
+ // Revocation is the operation the product is sold on, so the state change
1469
+ // and the event proving it happened must not be separable. Both are in this
1470
+ // transaction.
1471
+ //
1472
+ // LOCK ORDERING, and it is not decorative. Because `audit_append()` takes a
1473
+ // `pg_advisory_XACT_lock` on the org's chain, a transaction now holds that
1474
+ // lock from its FIRST audit write until commit -- which it did not before,
1475
+ // when every statement was its own transaction. Two transactions that take
1476
+ // the chain lock and a `file_grant` row lock in OPPOSITE orders deadlock.
1477
+ // The rule, followed by every method here, is:
1478
+ //
1479
+ // THE AUDIT CHAIN LOCK IS ALWAYS TAKEN BEFORE ANY ROW LOCK.
1480
+ //
1481
+ // `authorizeRevoke` emits its allow event (chain lock) before the UPDATE
1482
+ // below (row lock), and the delivery path likewise audits in `authorize()`
1483
+ // before `consume_download()` touches the grant row. This SELECT therefore
1484
+ // deliberately does NOT take `FOR UPDATE`: that would grab the row lock
1485
+ // first and invert the order against every other path. Nothing is lost --
1486
+ // the UPDATE is `WHERE revoked_at IS NULL`, so a concurrent revoke is
1487
+ // idempotent rather than a lost update.
1488
+ await this.#transaction(async (tx, store) => {
1489
+ const { rows } = await tx.query<{ file_id: string; org_id: string }>(
1490
+ `SELECT file_id, org_id FROM file_grant WHERE id = $1`,
1491
+ [grantId],
1492
+ );
1493
+ const g = rows[0];
1494
+ // Unknown grant and "not yours" are the same answer, for the same reason
1495
+ // file ids are: otherwise this endpoint is a grant-id oracle.
1496
+ if (!g) throw new FilelayerError(404, 'not_found');
1497
+
1498
+ const decision = await authorizeRevoke(store, principal, {
1499
+ id: grantId,
1500
+ fileId: g.file_id,
1501
+ orgId: g.org_id,
1502
+ });
1503
+ this.#raise(decision);
1504
+
1505
+ await tx.query(
1506
+ `UPDATE file_grant SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`,
1507
+ [grantId],
1508
+ );
1509
+ await store.audit({
1510
+ orgId: g.org_id,
1511
+ action: 'grant.revoke',
1512
+ decision: 'allow',
1513
+ actorId: principal.actorId,
1514
+ fileId: g.file_id,
1515
+ grantId,
1516
+ });
1517
+ });
1518
+ }
1519
+
1520
+ async listGrants(principal: Principal, fileId: string): Promise<GrantSummary[]> {
1521
+ const decision = await authorize(this.store, principal, fileId, 'share');
1522
+ this.#raise(decision);
1523
+
1524
+ const { rows } = await this.db.query<Record<string, unknown>>(
1525
+ `SELECT id, file_id, parent_grant_id, subject_type, subject_id,
1526
+ subject_org_id, subject_min_role, capabilities,
1527
+ (password_hash IS NOT NULL) AS has_password,
1528
+ expires_at, max_downloads, download_count, revoked_at,
1529
+ grant_is_live(id) AS live,
1530
+ created_by, created_at
1531
+ FROM file_grant WHERE file_id = $1 ORDER BY created_at ASC`,
1532
+ [fileId],
1533
+ );
1534
+ // Note what is NOT selected: secret_hash and password_hash. A "list what
1535
+ // we've shared" screen is exactly where a hash would leak into a log.
1536
+ return rows.map((r) => ({
1537
+ id: r['id'] as string,
1538
+ fileId: r['file_id'] as string,
1539
+ parentGrantId: (r['parent_grant_id'] as string | null) ?? null,
1540
+ subjectType: r['subject_type'] as GrantSubjectType,
1541
+ subjectId: (r['subject_id'] as string | null) ?? null,
1542
+ subjectOrgId: (r['subject_org_id'] as string | null) ?? null,
1543
+ subjectMinRole: (r['subject_min_role'] as OrgRole | null) ?? null,
1544
+ capabilities: toCapabilities(r['capabilities'] as Capability[] | string),
1545
+ hasPassword: Boolean(r['has_password']),
1546
+ expiresAt: r['expires_at'] ? new Date(r['expires_at'] as string) : null,
1547
+ maxDownloads: (r['max_downloads'] as number | null) ?? null,
1548
+ downloadCount: Number(r['download_count']),
1549
+ revokedAt: r['revoked_at'] ? new Date(r['revoked_at'] as string) : null,
1550
+ live: Boolean(r['live']),
1551
+ createdBy: (r['created_by'] as string | null) ?? null,
1552
+ createdAt: new Date(r['created_at'] as string),
1553
+ }));
1554
+ }
1555
+
1556
+ /**
1557
+ * The share-link download path.
1558
+ *
1559
+ * Order matters: authorize FIRST (which re-validates the grant and its whole
1560
+ * ancestor chain against `live_grant` -- P4, revocation beats a live URL),
1561
+ * then consume the counter atomically (P6). Consuming before authorizing
1562
+ * would let a revoked link burn a download; authorizing without consuming
1563
+ * would make the cap a suggestion.
1564
+ */
1565
+ async redeem(
1566
+ linkSecret: string,
1567
+ opts: {
1568
+ password?: string;
1569
+ ip?: string;
1570
+ userAgent?: string;
1571
+ disposition?: Disposition;
1572
+ } = {},
1573
+ ): Promise<{
1574
+ file: FileRecord;
1575
+ body: Uint8Array;
1576
+ headers: Record<string, string>;
1577
+ remainingDownloads: number | null;
1578
+ }> {
1579
+ // Buffered convenience form of `redeemStream()`, exactly as `read()` is of
1580
+ // `readStream()`. Forces proxy mode for the same reason.
1581
+ const d = await this.redeemStream(linkSecret, { ...opts, mode: 'proxy' });
1582
+ if (d.mode !== 'proxy') throw new FilelayerError(500, 'internal', 'unexpected_redirect');
1583
+ return {
1584
+ file: d.file,
1585
+ body: await collectStream(d.body),
1586
+ headers: d.headers,
1587
+ remainingDownloads: d.remainingDownloads,
1588
+ };
1589
+ }
1590
+
1591
+ /** The streaming share-link path. See `redeem()` for the ordering rationale. */
1592
+ async redeemStream(
1593
+ linkSecret: string,
1594
+ opts: {
1595
+ password?: string;
1596
+ ip?: string;
1597
+ userAgent?: string;
1598
+ disposition?: Disposition;
1599
+ mode?: 'proxy' | 'auto';
1600
+ range?: { start: number; end?: number };
1601
+ } = {},
1602
+ ): Promise<StreamedDelivery & { file: FileRecord; remainingDownloads: number | null }> {
1603
+ const principal: Principal = {
1604
+ actorId: null,
1605
+ linkSecret,
1606
+ ...(opts.password !== undefined ? { password: opts.password } : {}),
1607
+ ...(opts.ip !== undefined ? { ip: opts.ip } : {}),
1608
+ ...(opts.userAgent !== undefined ? { userAgent: opts.userAgent } : {}),
1609
+ };
1610
+
1611
+ const hash = await this.store.hashSecret(linkSecret);
1612
+
1613
+ const reserved = await this.#transaction(async (tx, store) => {
1614
+ // Resolve the secret to a FILE, not to an authorization. This
1615
+ // deliberately reads through the non-live lookup: a revoked, expired or
1616
+ // exhausted link must still reach the engine so that the denial is
1617
+ // attributed to the right tenant and recorded with the right reason.
1618
+ // Nothing here grants anything -- `authorize` below re-resolves through
1619
+ // `live_grant`.
1620
+ const grant = await store.findGrantBySecret(hash);
1621
+ if (!grant) {
1622
+ // No file and no tenant: the system chain exists precisely so that a
1623
+ // brute-force sweep against the credential itself is not invisible.
1624
+ // In the transaction, so the sweep cannot be made invisible by a
1625
+ // failure on the way out either.
1626
+ await auditUnresolvedSecret(store, principal, hash);
1627
+ throw new FilelayerError(404, 'not_found', 'bad_link_secret');
1628
+ }
1629
+
1630
+ const decision = await authorize(store, principal, grant.fileId, 'read');
1631
+ this.#raise(decision);
1632
+
1633
+ // Same reservation path as `readStream()`: one place charges the cap, one
1634
+ // place decides the mode, one place computes the headers. `no-store` on
1635
+ // the proxied response is not cosmetic here -- immediate revocation is the
1636
+ // product's headline property, and a cacheable share response makes a
1637
+ // revoked link replayable from the recipient's disk cache or from any
1638
+ // intermediary.
1639
+ return this.#reserve(tx, store, grant.fileId, decision, principal, opts);
1640
+ });
1641
+
1642
+ return this.#fetchDelivery(reserved, opts);
1643
+ }
1644
+
1645
+ // ---------------------------------------------------------------------------
1646
+ // Audit
1647
+ // ---------------------------------------------------------------------------
1648
+
1649
+ /** Requires `read_audit` in the org, which is admin+. Asked of the engine. */
1650
+ async auditLog(
1651
+ principal: Principal,
1652
+ orgId: string,
1653
+ filter: {
1654
+ decision?: 'allow' | 'deny';
1655
+ fileId?: string;
1656
+ actorId?: string;
1657
+ action?: string;
1658
+ limit?: number;
1659
+ } = {},
1660
+ ): Promise<AuditRow[]> {
1661
+ const decision = await authorizeOrg(this.store, principal, orgId, 'read_audit', {
1662
+ action: 'audit.read',
1663
+ emitAllow: false, // reading the log should not spam the log
1664
+ });
1665
+ this.#raise(decision);
1666
+ return this.store.listAudit(orgId, filter);
1667
+ }
1668
+
1669
+ /**
1670
+ * Verify the tamper-evidence chain for an org.
1671
+ *
1672
+ * Authorized, because `checked` is a count of everything that has ever
1673
+ * happened in the org and an unauthenticated caller should not be able to
1674
+ * measure another tenant's activity.
1675
+ */
1676
+ async verifyAuditChain(principal: Principal, orgId: string): Promise<AuditChainResult> {
1677
+ const decision = await authorizeOrg(this.store, principal, orgId, 'read_audit', {
1678
+ action: 'audit.verify',
1679
+ emitAllow: false,
1680
+ });
1681
+ this.#raise(decision);
1682
+ return this.store.verifyAuditChain(orgId);
1683
+ }
1684
+
1685
+ // ---------------------------------------------------------------------------
1686
+ // Internals
1687
+ // ---------------------------------------------------------------------------
1688
+
1689
+ /**
1690
+ * Turn a denial into an HTTP-shaped error.
1691
+ *
1692
+ * That is all it does now. It used to additionally re-run the whole
1693
+ * authorization question in order to downgrade 410/409 to 404 for callers
1694
+ * with no standing, because `authorize()` evaluated lifecycle gates before
1695
+ * establishing standing and therefore leaked existence. The engine
1696
+ * evaluates standing first now, so there is nothing left to compensate for --
1697
+ * and, not incidentally, one fewer place for a second entry point to forget.
1698
+ */
1699
+ #raise(decision: Decision): asserts decision is Extract<Decision, { allow: true }> {
1700
+ if (decision.allow) return;
1701
+ const pub = toPublicError(decision.reason);
1702
+ throw new FilelayerError(pub.status, pub.code, decision.reason);
1703
+ }
1704
+ }
1705
+
1706
+ /**
1707
+ * A file's full record from its id, with NO principal.
1708
+ *
1709
+ * IT IS A MODULE-LEVEL FUNCTION, NOT A PRIVATE METHOD, AND THAT IS THE POINT.
1710
+ *
1711
+ * This was once a PUBLIC method taking a file id and no principal, returning
1712
+ * name, content type, size, storage key, owner and org for any file in the
1713
+ * database. It sat under an `// Internals` comment, which binds nobody:
1714
+ * `fl.getFileRecord(anyUuid)` was a complete cross-tenant metadata read with no
1715
+ * decision, no denial and no audit event. A resource id without a principal is
1716
+ * not a question the system is allowed to answer.
1717
+ *
1718
+ * Making it `private` fixed the TYPE and not the RUNTIME. TypeScript's `private`
1719
+ * is erased at compile time: `(fl as any).getFileRecord(id)` still worked, and
1720
+ * so did `fl['getFileRecord'](id)` from plain JavaScript -- which is what an SDK
1721
+ * consumer actually holds. A test in test/persistence.test.ts caught exactly
1722
+ * that. Module scope is the only privacy JavaScript actually enforces, so the
1723
+ * function lives out here, where nothing outside this file can name it.
1724
+ *
1725
+ * Every caller inside the class reaches it only AFTER `authorize()` has
1726
+ * returned allow for the same file; the authorized replacement for external
1727
+ * callers is `stat()`. It is project-scoped, so even an internal caller cannot
1728
+ * read across a project boundary.
1729
+ */
1730
+ async function getFileRecord(
1731
+ db: Queryable,
1732
+ projectId: string | null,
1733
+ fileId: string,
1734
+ ): Promise<FileRecord | null> {
1735
+ if (!isUuid(fileId)) return null;
1736
+ const { rows } = await db.query<Record<string, unknown>>(
1737
+ `SELECT id, org_id, owner_id, name, content_type, size_bytes,
1738
+ storage_provider, storage_key,
1739
+ state, visibility, expires_at, retain_until, created_at, deleted_at
1740
+ FROM file WHERE id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid)`,
1741
+ [fileId, projectId],
1742
+ );
1743
+ return rows[0] ? toFileRecord(rows[0]) : null;
1744
+ }
1745
+
1746
+ /**
1747
+ * Listing options.
1748
+ *
1749
+ * Note what is absent: any way to express a WHERE clause, a raw filter, an
1750
+ * "include everything" flag, or a way to name another org. Everything here
1751
+ * NARROWS the authorized set; nothing widens it. That is what "fail-closed by
1752
+ * construction" has to mean for a query API -- not that the default is safe,
1753
+ * but that the unsafe result is not expressible.
1754
+ */
1755
+ export interface ListFilesOptions {
1756
+ /** Which capability the caller must hold. Default `read`. */
1757
+ capability?: Capability;
1758
+ /** Page size. Clamped to [1, 200]. */
1759
+ limit?: number;
1760
+ /** Opaque keyset cursor from a previous page's `nextCursor`. */
1761
+ cursor?: string | null;
1762
+ }
1763
+
1764
+ export interface FileListPage {
1765
+ files: FileRecord[];
1766
+ /** Null when this is the last page. */
1767
+ nextCursor: string | null;
1768
+ }
1769
+
1770
+ /**
1771
+ * Keyset pagination over (created_at, id).
1772
+ *
1773
+ * Keyset, not OFFSET: with OFFSET a row inserted or deleted between pages
1774
+ * shifts the window and a file silently skips a page, which on a compliance
1775
+ * listing screen is a file the reviewer never saw.
1776
+ *
1777
+ * The cursor is opaque but not authenticated, and it does not need to be: it
1778
+ * carries only a position, it is applied AFTER the authorization predicate, and
1779
+ * it is confined to the org named in the call. A forged cursor can move you
1780
+ * within your own authorized set and nowhere else. It is validated on the way
1781
+ * in so that a malformed one is a 400 rather than a silently-ignored filter.
1782
+ */
1783
+ function encodeCursor(createdAt: Date, id: string): string {
1784
+ return Buffer.from(`${createdAt.toISOString()}|${id}`, 'utf8').toString('base64url');
1785
+ }
1786
+
1787
+ function decodeCursor(cursor: string | null | undefined): { createdAt: Date; id: string } | null {
1788
+ if (cursor === undefined || cursor === null || cursor === '') return null;
1789
+ let decoded: string;
1790
+ try {
1791
+ decoded = Buffer.from(cursor, 'base64url').toString('utf8');
1792
+ } catch {
1793
+ throw new FilelayerError(400, 'bad_cursor');
1794
+ }
1795
+ const sep = decoded.lastIndexOf('|');
1796
+ if (sep < 0) throw new FilelayerError(400, 'bad_cursor');
1797
+ const createdAt = new Date(decoded.slice(0, sep));
1798
+ const id = decoded.slice(sep + 1);
1799
+ if (Number.isNaN(createdAt.getTime()) || !isUuid(id)) {
1800
+ throw new FilelayerError(400, 'bad_cursor');
1801
+ }
1802
+ return { createdAt, id };
1803
+ }
1804
+
1805
+ /**
1806
+ * Postgres array literal. The driver will not serialize a JS array into an
1807
+ * enum[] parameter, and a silent misencoding here would either error loudly
1808
+ * (fine) or store a single bogus capability (not fine), so the encoding is
1809
+ * explicit. Enum labels are a closed set of [a-z]+ and cannot contain a
1810
+ * separator, so no quoting is required -- but we assert that rather than
1811
+ * assume it.
1812
+ */
1813
+ export function pgArrayLiteral(values: readonly string[]): string {
1814
+ for (const v of values) {
1815
+ if (!/^[a-z_]+$/.test(v)) throw new Error(`unexpected capability literal: ${v}`);
1816
+ }
1817
+ return `{${values.join(',')}}`;
1818
+ }
1819
+
1820
+ function toFileRecord(r: Record<string, unknown>): FileRecord {
1821
+ return {
1822
+ id: r['id'] as string,
1823
+ orgId: r['org_id'] as string,
1824
+ ownerId: (r['owner_id'] as string | null) ?? null,
1825
+ name: r['name'] as string,
1826
+ contentType: r['content_type'] as string,
1827
+ sizeBytes: r['size_bytes'] == null ? null : Number(r['size_bytes']),
1828
+ storageProvider: r['storage_provider'] as string,
1829
+ storageKey: r['storage_key'] as string,
1830
+ state: r['deleted_at'] ? 'deleted' : (r['state'] as 'pending' | 'ready' | 'deleted'),
1831
+ visibility: r['visibility'] as FileVisibility,
1832
+ expiresAt: r['expires_at'] ? new Date(r['expires_at'] as string) : null,
1833
+ retainUntil: r['retain_until'] ? new Date(r['retain_until'] as string) : null,
1834
+ createdAt: new Date(r['created_at'] as string),
1835
+ };
1836
+ }