@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
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './authz.ts';
2
+ export * from './db.ts';
3
+ export * from './storage.ts';
4
+ export * from './store.ts';
5
+ export * from './filelayer.ts';
6
+ export * from './simple.ts';
7
+ export * from './delivery.ts';
package/src/simple.ts ADDED
@@ -0,0 +1,666 @@
1
+ /**
2
+ * PROGRESSIVE DISCLOSURE -- the tiered surface.
3
+ *
4
+ * This file exists to answer one question:
5
+ *
6
+ * "A developer who only needs a public avatar should be able to use an
7
+ * extremely simple version. A developer who needs a multi-tenant document
8
+ * system should be able to turn on the advanced capabilities."
9
+ *
10
+ * WHAT THIS FILE IS
11
+ * -----------------
12
+ * A thin, additive facade over `Filelayer`. It provisions the concepts the
13
+ * developer did not ask for (a workspace, a service identity, a membership) so
14
+ * that the trivial case costs one line, and it names the concepts the developer
15
+ * DID ask for (an owner, an org, a role) so that the advanced case is reachable
16
+ * by adding an option rather than by rewriting.
17
+ *
18
+ * WHAT THIS FILE IS NOT
19
+ * ---------------------
20
+ * It contains **no authorization logic**. Every call below goes through the same
21
+ * `authz.ts` engine as the full API, with the same audit guarantee. Grep it: the
22
+ * only `if` statements are about which identifier to resolve, never about
23
+ * whether access is permitted.
24
+ *
25
+ * THE FOUR SECURITY PROPERTIES, AND HOW EACH SURVIVES THE ERGONOMICS
26
+ * -----------------------------------------------------------------
27
+ * P1 (deny by default, no public boolean).
28
+ * `{ public: true }` does NOT set a flag. It creates an `anonymous` grant
29
+ * row -- the same row `share({subject:{type:'anonymous'}})` creates -- which
30
+ * is explicit, listable, revocable and audited. `unpublish()` revokes it.
31
+ * There is still no `file.public` column and there never will be.
32
+ *
33
+ * P2 (no ambient authority).
34
+ * Auto-provisioning creates IDENTITIES, never PERMISSIONS beyond the file
35
+ * the caller is creating. A user auto-registered by `put({owner})` gets the
36
+ * `member` role, and `member` + the default `private` visibility means they
37
+ * can read exactly their own files and nothing else. Reads never
38
+ * auto-provision: an unknown `as:` is a 404, not a silent downgrade to
39
+ * anonymous. That last rule is the one that would have been easy to get
40
+ * wrong and is tested explicitly.
41
+ *
42
+ * P3 (tenant isolation is structural).
43
+ * Untouched. Every file still has a real `org_id`. The "no org" experience
44
+ * is a DEFAULT org, not a NULL one -- see ARCHITECTURE-PROGRESSIVE.md for
45
+ * the measurement showing that a nullable `org_id` silently disables the
46
+ * composite foreign key that P3 rests on.
47
+ *
48
+ * P4 (a URL never outlives its permission).
49
+ * The public URL is `/f/:id` served by our own delivery path, which
50
+ * re-authorizes on every request. Revoke the anonymous grant and the URL is
51
+ * dead on the next request. This is the property a public S3 bucket and
52
+ * Supabase's `getPublicUrl` cannot offer at any price. It is ALSO the
53
+ * property that CDN caching would weaken -- see `delivery.ts`, which
54
+ * defaults to `no-store` for that reason.
55
+ */
56
+
57
+ import {
58
+ FilelayerError,
59
+ type Filelayer,
60
+ type FileRecord,
61
+ type ShareSubject,
62
+ } from './filelayer.ts';
63
+ import { DEFAULT_PROJECT_ID } from './store.ts';
64
+ import type { Capability, FileVisibility, OrgRole, Principal } from './authz.ts';
65
+
66
+ /**
67
+ * The org every file lands in when the developer never mentioned an org.
68
+ *
69
+ * A reserved `external_id`, not a magic NULL. Tier 3 is reached by naming a
70
+ * different org, not by turning a boundary on.
71
+ */
72
+ export const DEFAULT_WORKSPACE = '__filelayer_workspace__';
73
+
74
+ /**
75
+ * The identity that owns files uploaded with no `owner:`.
76
+ *
77
+ * It is the `owner` of the default workspace (so tier 1 works with no user
78
+ * model at all) and a plain `member` of any org the developer names (so it does
79
+ * NOT acquire admin read over a real tenant's documents as a side effect of
80
+ * being used for an unowned upload).
81
+ */
82
+ export const SYSTEM_ACTOR = '__filelayer_system__';
83
+
84
+ export interface PutOptions {
85
+ /** Defaults to `file`. Cosmetic; used for Content-Disposition. */
86
+ name?: string;
87
+ /** Sniffed from magic bytes when omitted; `application/octet-stream` if unknown. */
88
+ contentType?: string;
89
+
90
+ // --- tier 1 ---------------------------------------------------------------
91
+ /**
92
+ * Create an anonymous read grant alongside the file, and return a URL.
93
+ *
94
+ * This is one line of sugar over `share({subject:{type:'anonymous'}})`. It is
95
+ * not a flag on the file. `unpublish()` takes it away, immediately, for URLs
96
+ * already in the wild.
97
+ */
98
+ public?: boolean;
99
+
100
+ // --- tier 2 ---------------------------------------------------------------
101
+ /**
102
+ * Your own user id. Auto-registered on first use and made a `member` of the
103
+ * target workspace. With the default `private` visibility this means: they
104
+ * can read their own files, and nobody else's.
105
+ */
106
+ owner?: string;
107
+
108
+ // --- tier 3 ---------------------------------------------------------------
109
+ /** Your own tenant id. Auto-created on first use. Defaults to the workspace. */
110
+ org?: string;
111
+ /** `private` (default) = owner + org admins. `org` = every member may read. */
112
+ visibility?: FileVisibility;
113
+
114
+ // --- tier 4 ---------------------------------------------------------------
115
+ expiresIn?: number;
116
+ retainFor?: number;
117
+ metadata?: Record<string, unknown>;
118
+ }
119
+
120
+ export interface PutResult {
121
+ id: string;
122
+ name: string;
123
+ contentType: string;
124
+ size: number;
125
+ /** Present only for `public: true` files, and only when `baseUrl` is set. */
126
+ url?: string;
127
+ }
128
+
129
+ export interface AsOption {
130
+ /**
131
+ * Act as this user. Omit for an anonymous caller, which can read only files
132
+ * carrying an anonymous grant.
133
+ *
134
+ * An `as:` naming a user we have never seen is a DENIAL, not an anonymous
135
+ * read. Falling back would be the exact "ambient authority" failure P2 exists
136
+ * to prevent.
137
+ */
138
+ as?: string;
139
+ }
140
+
141
+ export interface GetResult {
142
+ id: string;
143
+ name: string;
144
+ contentType: string;
145
+ body: Uint8Array;
146
+ /** The response headers these bytes must be served with. See delivery.ts. */
147
+ headers: Record<string, string>;
148
+ }
149
+
150
+ // -----------------------------------------------------------------------------
151
+ // Content sniffing
152
+ // -----------------------------------------------------------------------------
153
+ //
154
+ // Deliberately a short, conservative list of formats with unambiguous magic
155
+ // bytes. Everything else is `application/octet-stream`, which -- combined with
156
+ // the `X-Content-Type-Options: nosniff` and sandbox CSP headers in delivery.ts
157
+ // -- means an unrecognised upload is downloaded rather than executed. SVG is
158
+ // absent on purpose: it has no reliable magic number and it is a script
159
+ // execution context. Pass `contentType` explicitly if you need one.
160
+
161
+ const MAGIC: Array<[number[], string]> = [
162
+ [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 'image/png'],
163
+ [[0xff, 0xd8, 0xff], 'image/jpeg'],
164
+ [[0x47, 0x49, 0x46, 0x38], 'image/gif'],
165
+ [[0x25, 0x50, 0x44, 0x46], 'application/pdf'],
166
+ ];
167
+
168
+ export function sniffContentType(body: Uint8Array): string {
169
+ for (const [sig, type] of MAGIC) {
170
+ if (sig.every((b, i) => body[i] === b)) return type;
171
+ }
172
+ // RIFF....WEBP
173
+ if (
174
+ body[0] === 0x52 && body[1] === 0x49 && body[2] === 0x46 && body[3] === 0x46 &&
175
+ body[8] === 0x57 && body[9] === 0x45 && body[10] === 0x42 && body[11] === 0x50
176
+ ) {
177
+ return 'image/webp';
178
+ }
179
+ return 'application/octet-stream';
180
+ }
181
+
182
+ // -----------------------------------------------------------------------------
183
+ // Identity resolution
184
+ // -----------------------------------------------------------------------------
185
+
186
+ /** The subset of `Filelayer` this facade drives. Declared to keep the import graph one-way. */
187
+ type Core = Filelayer;
188
+
189
+ class Identities {
190
+ private readonly fl: Core;
191
+
192
+ constructor(fl: Core) {
193
+ this.fl = fl;
194
+ }
195
+
196
+ /** The project this facade resolves identities in. See P8 in schema.sql. */
197
+ private get project(): string {
198
+ return this.fl.projectId ?? DEFAULT_PROJECT_ID;
199
+ }
200
+
201
+ /**
202
+ * Get-or-create an org by the customer's own id.
203
+ *
204
+ * `ON CONFLICT (project_id, external_id)` rather than a read-then-write, so
205
+ * two cold requests racing to publish the first avatar cannot produce a
206
+ * duplicate-key error in the developer's face.
207
+ *
208
+ * P8 (THE CUSTOMER'S ID SPACE IS THE CUSTOMER'S), AND THIS IS THE SHARPEST
209
+ * EDGE OF IT. When `external_id` was globally
210
+ * unique, this exact statement was a cross-tenant compromise: customer B
211
+ * calling `put({ org: 'acme' })` did not get an error, it got customer A's
212
+ * org id -- and `membership()` below then added B's user to A's tenant. The
213
+ * conflict target is now the (project, external_id) pair, so "acme" in one
214
+ * customer's application and "acme" in another are different rows that can
215
+ * never resolve to each other.
216
+ */
217
+ async org(externalId: string, opts: { name?: string; ownerActorId?: string } = {}): Promise<string> {
218
+ const { rows } = await this.fl.store.db.query<{ id: string }>(
219
+ `INSERT INTO org (project_id, external_id, name) VALUES ($3, $1, $2)
220
+ ON CONFLICT (project_id, external_id) DO UPDATE SET external_id = EXCLUDED.external_id
221
+ RETURNING id`,
222
+ [externalId, opts.name ?? externalId, this.project],
223
+ );
224
+ const id = rows[0]!.id;
225
+ if (opts.ownerActorId) await this.membership(id, opts.ownerActorId, 'owner', true);
226
+ return id;
227
+ }
228
+
229
+ /**
230
+ * Get-or-create an identity by the customer's own id, within this project.
231
+ *
232
+ * FAILS CLOSED ON A DELETED IDENTITY. Auto-provisioning must never resurrect
233
+ * someone who has been deleted: `ON CONFLICT DO UPDATE` would otherwise hand
234
+ * back a soft-deleted actor's id, and the caller would go on to create a file
235
+ * owned by a person the system has been told no longer exists. Access would
236
+ * still be denied downstream (P7 kills their role and their grants), so the
237
+ * failure is fail-closed either way -- but silently owning rows to a deleted
238
+ * identity is a data-integrity mess, not a security decision we should be
239
+ * making by accident.
240
+ */
241
+ async actor(externalId: string): Promise<string> {
242
+ const { rows } = await this.fl.store.db.query<{ id: string; deleted_at: string | null }>(
243
+ `INSERT INTO actor (project_id, external_id) VALUES ($2, $1)
244
+ ON CONFLICT (project_id, external_id) DO UPDATE SET external_id = EXCLUDED.external_id
245
+ RETURNING id, deleted_at`,
246
+ [externalId, this.project],
247
+ );
248
+ const row = rows[0]!;
249
+ if (row.deleted_at !== null) {
250
+ throw new FilelayerError(404, 'not_found', 'deleted_actor');
251
+ }
252
+ return row.id;
253
+ }
254
+
255
+ /** Read-only lookup. Returns null for an unknown id -- callers must fail closed. */
256
+ async findActor(externalId: string): Promise<string | null> {
257
+ const { rows } = await this.fl.store.db.query<{ id: string }>(
258
+ `SELECT id FROM actor
259
+ WHERE external_id = $1 AND project_id = $2 AND deleted_at IS NULL`,
260
+ [externalId, this.project],
261
+ );
262
+ return rows[0]?.id ?? null;
263
+ }
264
+
265
+ /**
266
+ * Ensure a membership exists, WITHOUT ever downgrading an existing one.
267
+ *
268
+ * `DO NOTHING` and not `DO UPDATE`: auto-provisioning must never be able to
269
+ * demote an admin a developer deliberately promoted, nor promote a member.
270
+ * Deliberate role changes go through `orgs.setRole`, which is authorized.
271
+ */
272
+ async membership(orgId: string, actorId: string, role: OrgRole, bootstrap = false): Promise<void> {
273
+ const { rows } = await this.fl.store.db.query<{ inserted: boolean }>(
274
+ `INSERT INTO membership (org_id, actor_id, role) VALUES ($1,$2,$3)
275
+ ON CONFLICT (org_id, actor_id) DO NOTHING
276
+ RETURNING true AS inserted`,
277
+ [orgId, actorId, role],
278
+ );
279
+ // P5: an identity acquiring standing in an org is a privilege change and is
280
+ // audited like any other, even though it was implicit.
281
+ if (rows.length > 0) {
282
+ await this.fl.store.audit({
283
+ orgId,
284
+ action: bootstrap ? 'member.bootstrap' : 'member.add',
285
+ decision: 'allow',
286
+ actorId,
287
+ fileId: null,
288
+ context: { targetActorId: actorId, toRole: role, via: 'auto_provision' },
289
+ });
290
+ }
291
+ }
292
+ }
293
+
294
+ // -----------------------------------------------------------------------------
295
+ // files.*
296
+ // -----------------------------------------------------------------------------
297
+
298
+ export class FilesApi {
299
+ private readonly fl: Core;
300
+ private readonly ids: Identities;
301
+
302
+ constructor(fl: Core) {
303
+ this.fl = fl;
304
+ this.ids = new Identities(fl);
305
+ }
306
+
307
+ /**
308
+ * TIER 1: `put(bytes, { public: true })` -> a URL, no other concepts.
309
+ * TIER 2: `put(bytes, { owner: userId })` -> adds an owner.
310
+ * TIER 3: `put(bytes, { org, owner })` -> adds a tenant.
311
+ * TIER 4: `+ expiresIn / retainFor / metadata`
312
+ */
313
+ async put(body: Uint8Array, opts: PutOptions = {}): Promise<PutResult> {
314
+ const contentType = opts.contentType ?? sniffContentType(body);
315
+ const name = opts.name ?? 'file';
316
+
317
+ // Resolve the tenant. Naming an org is what promotes you from tier 2 to
318
+ // tier 3; not naming one puts you in the default workspace, which is a real
319
+ // org with a real id, not a hole in the model.
320
+ const isDefaultWorkspace = opts.org === undefined;
321
+ const system = await this.ids.actor(SYSTEM_ACTOR);
322
+ const orgId = isDefaultWorkspace
323
+ ? await this.ids.org(DEFAULT_WORKSPACE, { name: 'workspace', ownerActorId: system })
324
+ : await this.ids.org(opts.org!);
325
+
326
+ // Resolve the owner. `owner:` auto-registers, because the developer is
327
+ // asserting the identity exists in their system; we are not inventing it.
328
+ let ownerId: string;
329
+ if (opts.owner !== undefined) {
330
+ ownerId = await this.ids.actor(opts.owner);
331
+ await this.ids.membership(orgId, ownerId, 'member');
332
+ } else {
333
+ ownerId = system;
334
+ // In a NAMED org the service identity is only a `member`, so an unowned
335
+ // upload into a customer tenant does not hand us admin read over that
336
+ // tenant's documents. In the default workspace it is the owner, because
337
+ // somebody has to be.
338
+ if (!isDefaultWorkspace) await this.ids.membership(orgId, system, 'member');
339
+ }
340
+
341
+ // The uploader is a Principal, not a bare id: ownership is derived from the
342
+ // authorized identity, never supplied beside it. See the note on the
343
+ // signature of `upload()` in filelayer.ts.
344
+ const file = await this.fl.upload({ actorId: ownerId }, orgId, {
345
+ name,
346
+ contentType,
347
+ body,
348
+ ...(opts.visibility ? { visibility: opts.visibility } : {}),
349
+ ...(opts.expiresIn !== undefined ? { expiresIn: opts.expiresIn } : {}),
350
+ ...(opts.retainFor !== undefined ? { retainFor: opts.retainFor } : {}),
351
+ ...(opts.metadata ? { metadata: opts.metadata } : {}),
352
+ });
353
+
354
+ let url: string | undefined;
355
+ if (opts.public) {
356
+ // P1 intact: this is a grant row, not a flag. The uploader mints it, so
357
+ // it goes through `authorizeShare` and is audited as `grant.create`.
358
+ await this.fl.share({ actorId: ownerId }, file.id, { subject: { type: 'anonymous' } });
359
+ url = this.publicUrl(file.id);
360
+ }
361
+
362
+ return {
363
+ id: file.id,
364
+ name: file.name,
365
+ contentType: file.contentType,
366
+ size: file.sizeBytes ?? body.byteLength,
367
+ ...(url ? { url } : {}),
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Read a file as a user (`{ as }`), or anonymously (no options).
373
+ *
374
+ * `headers` is carried through from the delivery layer so that the simple
375
+ * tier is exactly as safe as the full one. A tier-2 app that writes
376
+ * `res.writeHead(200, { 'content-type': f.contentType })` re-opens the
377
+ * stored-XSS path; `res.writeHead(200, f.headers)` does not.
378
+ */
379
+ async get(fileId: string, opts: AsOption = {}): Promise<GetResult> {
380
+ const principal = await this.principal(opts);
381
+ const { file, body, headers } = await this.fl.read(principal, fileId);
382
+ return { id: file.id, name: file.name, contentType: file.contentType, body, headers };
383
+ }
384
+
385
+ async delete(fileId: string, opts: AsOption = {}): Promise<void> {
386
+ await this.fl.delete(await this.principal(opts), fileId);
387
+ }
388
+
389
+ /** Make an existing file publicly readable. Same grant row as `put({public:true})`. */
390
+ async publish(fileId: string, opts: AsOption = {}): Promise<{ url: string; grantId: string }> {
391
+ const principal = await this.principal(opts, { orSystem: true });
392
+ const g = await this.fl.share(principal, fileId, { subject: { type: 'anonymous' } });
393
+ return { url: this.publicUrl(fileId), grantId: g.grantId };
394
+ }
395
+
396
+ /**
397
+ * Withdraw public access. Revokes every live anonymous grant on the file.
398
+ *
399
+ * This is the operation a public bucket cannot perform: after it returns, a
400
+ * URL that has been printed, indexed and shared stops working on the very
401
+ * next request, because delivery re-authorizes every time.
402
+ */
403
+ async unpublish(fileId: string, opts: AsOption = {}): Promise<{ revoked: number }> {
404
+ const principal = await this.principal(opts, { orSystem: true });
405
+ const grants = await this.fl.listGrants(principal, fileId);
406
+ let revoked = 0;
407
+ for (const g of grants) {
408
+ if (g.subjectType !== 'anonymous' || !g.live) continue;
409
+ await this.fl.revoke(principal, g.id);
410
+ revoked++;
411
+ }
412
+ return { revoked };
413
+ }
414
+
415
+ /**
416
+ * The delivery URL for a public file. Meaningless without an anonymous grant.
417
+ *
418
+ * IT ALWAYS BUILDS ON `/f`, so whatever serves it has to be mounted there.
419
+ * `deliveryHandler(fl)` is -- its `filePrefix` defaults to `/f`. But
420
+ * `fileDownloadRoute(fl, { principal })` mounted on its own defaults to a
421
+ * `prefix` of `/files`, and a URL from here would 404 against it. Either
422
+ * mount `deliveryHandler`, or pass `prefix: '/f'` to `fileDownloadRoute`.
423
+ */
424
+ publicUrl(fileId: string): string {
425
+ return `${this.fl.baseUrl ?? ''}/f/${fileId}`;
426
+ }
427
+
428
+ /**
429
+ * Metadata only, no bytes, authorized identically to `get`.
430
+ *
431
+ * This used to call `read()` (fetching the whole object) and then
432
+ * `getFileRecord()` (unauthorized, and now private). It calls the authorized
433
+ * `stat()` instead, which means a metadata lookup no longer moves the bytes
434
+ * and -- since the cap counts bytes leaving (P6) -- no longer spends a
435
+ * download from a capped grant.
436
+ */
437
+ async stat(fileId: string, opts: AsOption = {}): Promise<FileRecord> {
438
+ return this.fl.stat(await this.principal(opts), fileId);
439
+ }
440
+
441
+ /**
442
+ * External user id -> Principal.
443
+ *
444
+ * FAILS CLOSED. An `as:` we cannot resolve is 404, never a silent demotion to
445
+ * an anonymous principal -- which would turn a typo in a user id into a read
446
+ * of every public file, and, worse, would make the audit log attribute it to
447
+ * nobody.
448
+ */
449
+ private async principal(opts: AsOption, cfg: { orSystem?: boolean } = {}): Promise<Principal> {
450
+ if (opts.as === undefined) {
451
+ if (!cfg.orSystem) return { actorId: null };
452
+ // publish/unpublish with no `as` is a server-side administrative action in
453
+ // the default workspace, performed by the service identity that owns it.
454
+ return { actorId: await this.ids.actor(SYSTEM_ACTOR) };
455
+ }
456
+ const actorId = await this.ids.findActor(opts.as);
457
+ if (!actorId) throw new FilelayerError(404, 'not_found', 'unknown_actor');
458
+ return { actorId };
459
+ }
460
+ }
461
+
462
+ // -----------------------------------------------------------------------------
463
+ // orgs.* -- tier 3
464
+ // -----------------------------------------------------------------------------
465
+
466
+ export class OrgsApi {
467
+ private readonly fl: Core;
468
+ private readonly ids: Identities;
469
+
470
+ constructor(fl: Core) {
471
+ this.fl = fl;
472
+ this.ids = new Identities(fl);
473
+ }
474
+
475
+ /**
476
+ * Create a tenant with its first owner. Idempotent.
477
+ *
478
+ * The owner is created WITH the org for the same reason `createOrg` does it:
479
+ * there is never a memberless org for someone to walk into.
480
+ */
481
+ async create(externalId: string, opts: { name?: string; owner: string }): Promise<{ id: string }> {
482
+ const ownerActorId = await this.ids.actor(opts.owner);
483
+ const id = await this.ids.org(externalId, {
484
+ ...(opts.name ? { name: opts.name } : {}),
485
+ ownerActorId,
486
+ });
487
+ return { id };
488
+ }
489
+
490
+ /**
491
+ * Add or change a member's role.
492
+ *
493
+ * `as` is REQUIRED and is not defaulted. Membership is the privilege that
494
+ * confers every other privilege; there is no convenience worth an unauthorized
495
+ * path to it. This is the one place where the tiered API is deliberately no
496
+ * shorter than the full API.
497
+ */
498
+ async setRole(
499
+ org: string,
500
+ user: string,
501
+ role: OrgRole,
502
+ opts: { as: string },
503
+ ): Promise<void> {
504
+ const { orgId, actorId, principal } = await this.trio(org, user, opts.as);
505
+ await this.fl.addMember(principal, orgId, actorId, role);
506
+ }
507
+
508
+ async removeMember(org: string, user: string, opts: { as: string }): Promise<void> {
509
+ const { orgId, actorId, principal } = await this.trio(org, user, opts.as);
510
+ await this.fl.removeMember(principal, orgId, actorId);
511
+ }
512
+
513
+ /** Audit trail for a tenant. Requires `read_audit`, i.e. admin or owner. */
514
+ async audit(
515
+ org: string,
516
+ opts: { as: string; decision?: 'allow' | 'deny'; limit?: number },
517
+ ) {
518
+ const orgId = await this.requireOrg(org);
519
+ const principal = await this.requirePrincipal(opts.as);
520
+ return this.fl.auditLog(principal, orgId, {
521
+ ...(opts.decision ? { decision: opts.decision } : {}),
522
+ ...(opts.limit ? { limit: opts.limit } : {}),
523
+ });
524
+ }
525
+
526
+ async verifyAudit(org: string, opts: { as: string }) {
527
+ const orgId = await this.requireOrg(org);
528
+ return this.fl.verifyAuditChain(await this.requirePrincipal(opts.as), orgId);
529
+ }
530
+
531
+ private async trio(org: string, user: string, as: string) {
532
+ const orgId = await this.requireOrg(org);
533
+ const actorId = await this.ids.actor(user); // the TARGET may be new
534
+ const principal = await this.requirePrincipal(as); // the CALLER may not
535
+ return { orgId, actorId, principal };
536
+ }
537
+
538
+ private async requireOrg(externalId: string): Promise<string> {
539
+ const { rows } = await this.fl.store.db.query<{ id: string }>(
540
+ `SELECT o.id FROM org o
541
+ JOIN project p ON p.id = o.project_id
542
+ WHERE o.external_id = $1
543
+ AND o.project_id = $2
544
+ AND o.deleted_at IS NULL
545
+ AND p.deleted_at IS NULL`,
546
+ [externalId, this.fl.projectId ?? DEFAULT_PROJECT_ID],
547
+ );
548
+ if (!rows[0]) throw new FilelayerError(404, 'not_found', 'unknown_org');
549
+ return rows[0].id;
550
+ }
551
+
552
+ private async requirePrincipal(as: string): Promise<Principal> {
553
+ const actorId = await this.ids.findActor(as);
554
+ if (!actorId) throw new FilelayerError(404, 'not_found', 'unknown_actor');
555
+ return { actorId };
556
+ }
557
+ }
558
+
559
+ // -----------------------------------------------------------------------------
560
+ // shares.* -- tier 4, expressed in external ids
561
+ // -----------------------------------------------------------------------------
562
+
563
+ export interface ShareOptions extends AsOption {
564
+ as: string;
565
+ expiresIn?: number;
566
+ maxDownloads?: number;
567
+ password?: string;
568
+ /** Share with a named user instead of minting a link. */
569
+ withUser?: string;
570
+ /**
571
+ * Share with EVERY MEMBER of an organization -- your own tenant id for it,
572
+ * the same string you pass as `org:` to `files.put`. May name a DIFFERENT org
573
+ * from the file's own ("the company that posted this job may read this CV");
574
+ * it must be an org in the same project.
575
+ *
576
+ * Resolution is a join against membership, evaluated per request: add or
577
+ * remove a member and their access changes on the very next call, with no
578
+ * grant row touched. Nothing is fanned out.
579
+ */
580
+ withOrg?: string;
581
+ /**
582
+ * With `withOrg`, narrows the grant to members at this role or above --
583
+ * `shares.create(id, { as, withOrg: 'acme', minRole: 'admin' })` is "admins
584
+ * of acme only". Omit it and every member matches, at any role.
585
+ *
586
+ * The four roles are the existing `viewer | member | admin | owner`. There
587
+ * are no custom roles: this is a floor over that enum, nothing more.
588
+ */
589
+ minRole?: OrgRole;
590
+ capabilities?: Capability[];
591
+ }
592
+
593
+ export class SharesApi {
594
+ private readonly fl: Core;
595
+ private readonly ids: Identities;
596
+
597
+ constructor(fl: Core) {
598
+ this.fl = fl;
599
+ this.ids = new Identities(fl);
600
+ }
601
+
602
+ async create(fileId: string, opts: ShareOptions) {
603
+ const actorId = await this.ids.findActor(opts.as);
604
+ if (!actorId) throw new FilelayerError(404, 'not_found', 'unknown_actor');
605
+ if (opts.withUser && opts.withOrg) {
606
+ throw new FilelayerError(400, 'ambiguous_subject', 'withUser_and_withOrg');
607
+ }
608
+ if (opts.minRole && !opts.withOrg) {
609
+ throw new FilelayerError(400, 'ambiguous_subject', 'minRole_without_withOrg');
610
+ }
611
+ const subject: ShareSubject = opts.withUser
612
+ ? { type: 'actor', actorId: await this.ids.actor(opts.withUser) }
613
+ : opts.withOrg
614
+ ? // The subject org must ALREADY exist. `withOrg` is not a
615
+ // get-or-create: auto-provisioning a tenant here would mean a typo in
616
+ // an org name silently mints a grant to an empty organization that
617
+ // anybody could later be added to, which is a permission granted to a
618
+ // population nobody has audited. Unknown org is 404, like `as:`.
619
+ opts.minRole
620
+ ? { type: 'role', orgId: await this.requireOrgId(opts.withOrg), minRole: opts.minRole }
621
+ : { type: 'org', orgId: await this.requireOrgId(opts.withOrg) }
622
+ : { type: 'link' };
623
+ return this.fl.share({ actorId }, fileId, {
624
+ subject,
625
+ ...(opts.capabilities ? { capabilities: opts.capabilities } : {}),
626
+ ...(opts.expiresIn !== undefined ? { expiresIn: opts.expiresIn } : {}),
627
+ ...(opts.maxDownloads !== undefined ? { maxDownloads: opts.maxDownloads } : {}),
628
+ ...(opts.password !== undefined ? { password: opts.password } : {}),
629
+ });
630
+ }
631
+
632
+ async revoke(grantId: string, opts: { as: string }) {
633
+ const actorId = await this.ids.findActor(opts.as);
634
+ if (!actorId) throw new FilelayerError(404, 'not_found', 'unknown_actor');
635
+ return this.fl.revoke({ actorId }, grantId);
636
+ }
637
+
638
+ async list(fileId: string, opts: { as: string }) {
639
+ const actorId = await this.ids.findActor(opts.as);
640
+ if (!actorId) throw new FilelayerError(404, 'not_found', 'unknown_actor');
641
+ return this.fl.listGrants({ actorId }, fileId);
642
+ }
643
+
644
+ /** Redeem a share link. No identity required -- the secret is the credential. */
645
+ async redeem(secret: string, opts: { password?: string; ip?: string } = {}) {
646
+ return this.fl.redeem(secret, opts);
647
+ }
648
+
649
+ /**
650
+ * External org id -> internal id, within this project. Read-only and
651
+ * fail-closed, exactly like `Identities.findActor`.
652
+ */
653
+ private async requireOrgId(externalId: string): Promise<string> {
654
+ const { rows } = await this.fl.store.db.query<{ id: string }>(
655
+ `SELECT o.id FROM org o
656
+ JOIN project p ON p.id = o.project_id
657
+ WHERE o.external_id = $1
658
+ AND o.project_id = $2
659
+ AND o.deleted_at IS NULL
660
+ AND p.deleted_at IS NULL`,
661
+ [externalId, this.fl.projectId ?? DEFAULT_PROJECT_ID],
662
+ );
663
+ if (!rows[0]) throw new FilelayerError(404, 'not_found', 'unknown_org');
664
+ return rows[0].id;
665
+ }
666
+ }