@arcforgelabs/flow 2026.10.50 → 2026.10.51

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.
@@ -0,0 +1,491 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { FlowError } from './errors.js';
5
+ import { deriveKey } from './secrets.js';
6
+
7
+ // The object storage port (docs/DATA_MODEL.md, "Storage"). One interface for
8
+ // every host: put, get, delete, and a signed link. PostgreSQL keeps the key,
9
+ // size, type and checksum; the bytes stay here.
10
+ //
11
+ // local a folder on one machine, and what tests use
12
+ // s3 any S3-compatible store, signed with AWS Signature Version 4
13
+ // replit Replit Object Storage, through @replit/object-storage
14
+ //
15
+ // A published Replit app keeps no files between publishes, so it never uses
16
+ // the local folder. Replit's client has no signed URL of its own, so a Replit
17
+ // link is a Flow link: Flow checks the signature, reads the bytes and checks
18
+ // the checksum. An S3 link is a presigned GET the store itself honours.
19
+
20
+ const KEY = /^(?:[a-z0-9][a-z0-9_-]{0,63}\/)*[a-z0-9][a-z0-9_-]{0,63}(?:\.[a-z0-9]{1,8})?$/;
21
+ const LINK_TTL_SECONDS = 15 * 60;
22
+ const EMPTY_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
23
+
24
+ function assertStorageKey(key) {
25
+ if (typeof key !== 'string' || !KEY.test(key) || key.includes('..')) {
26
+ throw new FlowError('invalid', 'storage key is not allowed');
27
+ }
28
+ }
29
+
30
+ const unavailable = () => new FlowError('storage_unavailable', 'the file store could not be reached');
31
+
32
+ function safeEqual(left, right) {
33
+ const a = Buffer.from(left);
34
+ const b = Buffer.from(right);
35
+ return a.length === b.length && timingSafeEqual(a, b);
36
+ }
37
+
38
+ // A Flow signed link. The token is the key and the expiry, HMAC-SHA256, so a
39
+ // change to either fails the check. The key is an id Flow chose, never a name
40
+ // from the photo.
41
+ function createLinkSigner(secret) {
42
+ if (!Buffer.isBuffer(secret) || secret.length < 32) throw new FlowError('invalid', 'storage signing key is required');
43
+ const key = secret.subarray(0, 32);
44
+
45
+ function sign(storageKey, expiresInSeconds, now) {
46
+ assertStorageKey(storageKey);
47
+ const expiresAt = new Date(now.getTime() + expiresInSeconds * 1000);
48
+ const payload = `${storageKey}\n${Math.floor(expiresAt.getTime() / 1000)}`;
49
+ const mac = createHmac('sha256', key).update(payload).digest('base64url');
50
+ return { token: `${Buffer.from(payload).toString('base64url')}.${mac}`, expiresAt: expiresAt.toISOString() };
51
+ }
52
+
53
+ function open(token, now) {
54
+ if (typeof token !== 'string' || token.length > 2000) return { ok: false, code: 'link_invalid' };
55
+ const dot = token.indexOf('.');
56
+ if (dot <= 0 || token.indexOf('.', dot + 1) !== -1) return { ok: false, code: 'link_invalid' };
57
+ let payload;
58
+ try {
59
+ payload = Buffer.from(token.slice(0, dot), 'base64url').toString('utf8');
60
+ } catch {
61
+ return { ok: false, code: 'link_invalid' };
62
+ }
63
+ const expected = createHmac('sha256', key).update(payload).digest('base64url');
64
+ if (!safeEqual(token.slice(dot + 1), expected)) return { ok: false, code: 'link_invalid' };
65
+ const nl = payload.lastIndexOf('\n');
66
+ const storageKey = payload.slice(0, nl);
67
+ const exp = Number(payload.slice(nl + 1));
68
+ if (!Number.isInteger(exp) || nl <= 0) return { ok: false, code: 'link_invalid' };
69
+ try {
70
+ assertStorageKey(storageKey);
71
+ } catch {
72
+ return { ok: false, code: 'link_invalid' };
73
+ }
74
+ if (exp <= Math.floor(now.getTime() / 1000)) return { ok: false, code: 'link_expired' };
75
+ return { ok: true, key: storageKey };
76
+ }
77
+
78
+ return { sign, open };
79
+ }
80
+
81
+ function flowLink(signer, { key, expiresInSeconds = LINK_TTL_SECONDS, baseUrl, now }) {
82
+ if (!baseUrl || !/^https?:\/\//.test(String(baseUrl))) throw new FlowError('invalid', 'a signed link needs an http origin');
83
+ const signed = signer.sign(key, expiresInSeconds, now());
84
+ const url = `${String(baseUrl).replace(/\/$/, '')}/api/captures/file?token=${encodeURIComponent(signed.token)}`;
85
+ return { url, expiresAt: signed.expiresAt };
86
+ }
87
+
88
+ function insideRoot(root, key) {
89
+ assertStorageKey(key);
90
+ const base = path.resolve(root);
91
+ const full = path.resolve(base, key);
92
+ const relative = path.relative(base, full);
93
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new FlowError('invalid', 'storage key is not allowed');
94
+ return full;
95
+ }
96
+
97
+ export function createLocalObjectStorage({ root, signingKey, now = () => new Date() }) {
98
+ const signer = createLinkSigner(signingKey);
99
+
100
+ return {
101
+ kind: 'local',
102
+ async put({ key, body, contentType }) {
103
+ const full = insideRoot(root, key);
104
+ if (!Buffer.isBuffer(body)) throw new FlowError('invalid', 'photo is required');
105
+ await mkdir(path.dirname(full), { recursive: true });
106
+ const temp = `${full}.${randomBytes(6).toString('hex')}.tmp`;
107
+ try {
108
+ await writeFile(temp, body, { flag: 'wx' });
109
+ await rename(temp, full);
110
+ if (contentType) await writeFile(`${full}.type`, String(contentType), { flag: 'w' });
111
+ } catch {
112
+ await rm(temp, { force: true });
113
+ await rm(full, { force: true });
114
+ throw unavailable();
115
+ }
116
+ },
117
+ async get(key) {
118
+ const full = insideRoot(root, key);
119
+ try {
120
+ const body = await readFile(full);
121
+ const contentType = await readFile(`${full}.type`, 'utf8').catch(() => null);
122
+ return { body, contentType };
123
+ } catch (error) {
124
+ if (error?.code === 'ENOENT') return null;
125
+ throw unavailable();
126
+ }
127
+ },
128
+ async delete(key) {
129
+ const full = insideRoot(root, key);
130
+ await rm(full, { force: true });
131
+ await rm(`${full}.type`, { force: true });
132
+ },
133
+ signedLink(options) {
134
+ return flowLink(signer, { ...options, now });
135
+ },
136
+ openLink(token) {
137
+ return signer.open(token, now());
138
+ },
139
+ };
140
+ }
141
+
142
+ function sha256Hex(body) {
143
+ return createHash('sha256').update(body ?? '').digest('hex');
144
+ }
145
+
146
+ function encodeRfc3986(value) {
147
+ return encodeURIComponent(value).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);
148
+ }
149
+
150
+ function encodedPath(segments) {
151
+ return `/${segments.map((segment) => encodeRfc3986(segment)).join('/')}`;
152
+ }
153
+
154
+ function amzDate(date) {
155
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
156
+ }
157
+
158
+ function hmac(key, data) {
159
+ return createHmac('sha256', key).update(data).digest();
160
+ }
161
+
162
+ function signingKey(secret, dateStamp, region, service) {
163
+ return hmac(hmac(hmac(hmac(`AWS4${secret}`, dateStamp), region), service), 'aws4_request');
164
+ }
165
+
166
+ // Canonical request as AWS Signature Version 4 defines it. `headers` is
167
+ // lowercase names to values, already trimmed.
168
+ function canonicalRequest({ method, path: uri, query = '', headers, payloadHash }) {
169
+ const names = Object.keys(headers).sort();
170
+ const canonicalHeaders = names.map((name) => `${name}:${headers[name]}\n`).join('');
171
+ return `${method}\n${uri}\n${query}\n${canonicalHeaders}\n${names.join(';')}\n${payloadHash}`;
172
+ }
173
+
174
+ export function authorizationHeader({
175
+ method, path: uri, query = '', headers, payloadHash, accessKeyId, secretAccessKey, region, service = 's3', now,
176
+ }) {
177
+ const stamp = amzDate(now);
178
+ const date = stamp.slice(0, 8);
179
+ const scope = `${date}/${region}/${service}/aws4_request`;
180
+ const request = canonicalRequest({ method, path: uri, query, headers, payloadHash });
181
+ const stringToSign = `AWS4-HMAC-SHA256\n${stamp}\n${scope}\n${sha256Hex(request)}`;
182
+ const signature = createHmac('sha256', signingKey(secretAccessKey, date, region, service)).update(stringToSign).digest('hex');
183
+ const signed = Object.keys(headers).sort().join(';');
184
+ return {
185
+ stamp,
186
+ signature,
187
+ requestHash: sha256Hex(request),
188
+ header: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope},SignedHeaders=${signed},Signature=${signature}`,
189
+ };
190
+ }
191
+
192
+ function locate({ bucket, region, key, endpoint, forcePathStyle }) {
193
+ const segments = key.split('/');
194
+ if (endpoint) {
195
+ const url = new URL(endpoint);
196
+ if (forcePathStyle) return { protocol: url.protocol, host: url.host, path: encodedPath([bucket, ...segments]) };
197
+ return { protocol: url.protocol, host: `${bucket}.${url.host}`, path: encodedPath(segments) };
198
+ }
199
+ const host = region === 'us-east-1' ? `${bucket}.s3.amazonaws.com` : `${bucket}.s3.${region}.amazonaws.com`;
200
+ return { protocol: 'https:', host, path: encodedPath(segments) };
201
+ }
202
+
203
+ function canonicalQuery(params) {
204
+ return Object.keys(params).sort().map((name) => `${encodeRfc3986(name)}=${encodeRfc3986(String(params[name]))}`).join('&');
205
+ }
206
+
207
+ export function presignedGetUrl({
208
+ accessKeyId, secretAccessKey, region, bucket, key, expiresInSeconds, endpoint = null, forcePathStyle = false, now,
209
+ }) {
210
+ assertStorageKey(key);
211
+ const located = locate({ bucket, region, key, endpoint, forcePathStyle });
212
+ const stamp = amzDate(now);
213
+ const date = stamp.slice(0, 8);
214
+ const scope = `${date}/${region}/s3/aws4_request`;
215
+ const params = {
216
+ 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
217
+ 'X-Amz-Credential': `${accessKeyId}/${scope}`,
218
+ 'X-Amz-Date': stamp,
219
+ 'X-Amz-Expires': String(expiresInSeconds),
220
+ 'X-Amz-SignedHeaders': 'host',
221
+ };
222
+ const query = canonicalQuery(params);
223
+ const signed = authorizationHeader({
224
+ method: 'GET',
225
+ path: located.path,
226
+ query,
227
+ headers: { host: located.host },
228
+ payloadHash: 'UNSIGNED-PAYLOAD',
229
+ accessKeyId,
230
+ secretAccessKey,
231
+ region,
232
+ now,
233
+ });
234
+ return { url: `${located.protocol}//${located.host}${located.path}?${query}&X-Amz-Signature=${signed.signature}`, expiresAt: new Date(now.getTime() + expiresInSeconds * 1000).toISOString(), signature: signed.signature, requestHash: signed.requestHash };
235
+ }
236
+
237
+ async function discard(res) {
238
+ await res.arrayBuffer().catch(() => {});
239
+ }
240
+
241
+ export function createS3ObjectStorage({
242
+ bucket, region, accessKeyId, secretAccessKey, endpoint = null, forcePathStyle = false, fetchImpl = globalThis.fetch, now = () => new Date(),
243
+ }) {
244
+ if (!bucket || !region || !accessKeyId || !secretAccessKey) throw new FlowError('invalid', 'S3 storage needs a bucket, region and keys');
245
+
246
+ async function call(method, key, { body = null, contentType = null } = {}) {
247
+ assertStorageKey(key);
248
+ const at = now();
249
+ const located = locate({ bucket, region, key, endpoint, forcePathStyle });
250
+ const payloadHash = body ? sha256Hex(body) : EMPTY_SHA256;
251
+ const headers = {
252
+ host: located.host,
253
+ 'x-amz-content-sha256': payloadHash,
254
+ 'x-amz-date': amzDate(at),
255
+ };
256
+ if (contentType) headers['content-type'] = contentType;
257
+ const { header } = authorizationHeader({
258
+ method, path: located.path, headers, payloadHash, accessKeyId, secretAccessKey, region, now: at,
259
+ });
260
+ const sent = { ...headers, authorization: header };
261
+ delete sent.host;
262
+ const res = await fetchImpl(`${located.protocol}//${located.host}${located.path}`, {
263
+ method,
264
+ headers: sent,
265
+ body: body ?? undefined,
266
+ }).catch(() => { throw unavailable(); });
267
+ return res;
268
+ }
269
+
270
+ return {
271
+ kind: 's3',
272
+ async put({ key, body, contentType }) {
273
+ if (!Buffer.isBuffer(body)) throw new FlowError('invalid', 'photo is required');
274
+ const res = await call('PUT', key, { body, contentType });
275
+ if (!res.ok) {
276
+ await discard(res);
277
+ throw unavailable();
278
+ }
279
+ await discard(res);
280
+ },
281
+ async get(key) {
282
+ const res = await call('GET', key);
283
+ if (res.status === 404) {
284
+ await discard(res);
285
+ return null;
286
+ }
287
+ if (!res.ok) {
288
+ await discard(res);
289
+ throw unavailable();
290
+ }
291
+ return { body: Buffer.from(await res.arrayBuffer()), contentType: res.headers.get('content-type') };
292
+ },
293
+ async delete(key) {
294
+ const res = await call('DELETE', key);
295
+ if (!res.ok && res.status !== 404) {
296
+ await discard(res);
297
+ throw unavailable();
298
+ }
299
+ await discard(res);
300
+ },
301
+ signedLink({ key, expiresInSeconds = LINK_TTL_SECONDS }) {
302
+ const signed = presignedGetUrl({
303
+ accessKeyId, secretAccessKey, region, bucket, key, expiresInSeconds, endpoint, forcePathStyle, now: now(),
304
+ });
305
+ return { url: signed.url, expiresAt: signed.expiresAt };
306
+ },
307
+ // An S3 link is opened by S3, not by Flow's file route.
308
+ openLink() {
309
+ return { ok: false, code: 'link_invalid' };
310
+ },
311
+ };
312
+ }
313
+
314
+ function bufferOf(value) {
315
+ if (Buffer.isBuffer(value)) return value;
316
+ if (Array.isArray(value) && Buffer.isBuffer(value[0])) return value[0];
317
+ return null;
318
+ }
319
+
320
+ function isMissing(error) {
321
+ if (!error) return false;
322
+ if (String(error.name ?? '') === 'ObjectNotFoundError') return true;
323
+ const code = error.code ?? error.status ?? error.statusCode;
324
+ return code === 404 || code === '404';
325
+ }
326
+
327
+ // `client` is a Replit Object Storage Client (uploadFromBytes, downloadAsBytes,
328
+ // delete). downloadAsBytes resolves to a one-item array of buffers.
329
+ export function createReplitObjectStorage({ client, signingKey, now = () => new Date() }) {
330
+ const signer = createLinkSigner(signingKey);
331
+
332
+ async function resultOf(promise) {
333
+ try {
334
+ return await promise;
335
+ } catch {
336
+ throw unavailable();
337
+ }
338
+ }
339
+
340
+ return {
341
+ kind: 'replit',
342
+ async put({ key, body }) {
343
+ assertStorageKey(key);
344
+ if (!Buffer.isBuffer(body)) throw new FlowError('invalid', 'photo is required');
345
+ const result = await resultOf(client.uploadFromBytes(key, body));
346
+ if (!result?.ok) throw unavailable();
347
+ },
348
+ async get(key) {
349
+ assertStorageKey(key);
350
+ const result = await resultOf(client.downloadAsBytes(key));
351
+ if (!result?.ok) {
352
+ if (isMissing(result?.error)) return null;
353
+ throw unavailable();
354
+ }
355
+ const body = bufferOf(result.value);
356
+ if (!body) throw unavailable();
357
+ return { body, contentType: null };
358
+ },
359
+ async delete(key) {
360
+ assertStorageKey(key);
361
+ const result = await resultOf(client.delete(key, { ignoreNotFound: true }));
362
+ if (!result?.ok && !isMissing(result?.error)) throw unavailable();
363
+ },
364
+ signedLink(options) {
365
+ return flowLink(signer, { ...options, now });
366
+ },
367
+ openLink(token) {
368
+ return signer.open(token, now());
369
+ },
370
+ };
371
+ }
372
+
373
+ function configuredSigningKey(env, secrets) {
374
+ const raw = String(env.FLOW_STORAGE_SIGNING_KEY ?? '').trim();
375
+ if (/^[0-9a-f]{64}$/i.test(raw)) return Buffer.from(raw, 'hex');
376
+ if (raw) return createHash('sha256').update(raw).digest();
377
+ if (secrets?.key) return deriveKey(secrets, 'storage-signing');
378
+ return null;
379
+ }
380
+
381
+ const FOLDER_BLOCKED = new Set(['EACCES', 'EPERM', 'EROFS', 'ENOTDIR']);
382
+
383
+ function rethrowKey(error) {
384
+ if (FOLDER_BLOCKED.has(error?.code)) throw error;
385
+ throw new Error('FLOW_STORAGE=local could not read its signing key');
386
+ }
387
+
388
+ async function readOrCreateKey(root) {
389
+ const file = path.join(path.resolve(root), '.signing-key');
390
+ try {
391
+ const existing = await readFile(file);
392
+ if (existing.length >= 32) return existing.subarray(0, 32);
393
+ } catch (error) {
394
+ if (error?.code !== 'ENOENT') rethrowKey(error);
395
+ }
396
+ await mkdir(path.dirname(file), { recursive: true });
397
+ const key = randomBytes(32);
398
+ try {
399
+ await writeFile(file, key, { flag: 'wx', mode: 0o600 });
400
+ return key;
401
+ } catch (error) {
402
+ if (error?.code !== 'EEXIST') rethrowKey(error);
403
+ }
404
+ const created = await readFile(file);
405
+ if (created.length >= 32) return created.subarray(0, 32);
406
+ throw new Error('FLOW_STORAGE=local could not read its signing key');
407
+ }
408
+
409
+ function onReplit(env) {
410
+ return Boolean(String(env.REPL_ID ?? '').trim() || String(env.REPLIT_DEPLOYMENT ?? '').trim());
411
+ }
412
+
413
+ function storageMode(env) {
414
+ const set = String(env.FLOW_STORAGE ?? '').trim().toLowerCase();
415
+ if (set) return set;
416
+ if (onReplit(env)) return 'off';
417
+ return 'local';
418
+ }
419
+
420
+ async function loadReplitClient() {
421
+ let Client;
422
+ try {
423
+ ({ Client } = await import('@replit/object-storage'));
424
+ } catch (error) {
425
+ if (error?.code === 'ERR_MODULE_NOT_FOUND') return null;
426
+ throw new Error('FLOW_STORAGE=replit could not load @replit/object-storage');
427
+ }
428
+ try {
429
+ return new Client();
430
+ } catch {
431
+ throw new Error('FLOW_STORAGE=replit could not open the bucket');
432
+ }
433
+ }
434
+
435
+ // Answers { storage, mode, reason }. `storage` is null when captures are off.
436
+ // A bad explicit setting throws, so boot stops. On Replit the default is off,
437
+ // never the local folder: a publish would drop those files. Where the local
438
+ // folder cannot be created, an unset FLOW_STORAGE is off too.
439
+ export async function resolveObjectStorage({ env = process.env, secrets = null, root = null } = {}) {
440
+ const mode = storageMode(env);
441
+ const explicit = Boolean(String(env.FLOW_STORAGE ?? '').trim());
442
+ if (!['local', 's3', 'replit', 'off'].includes(mode)) throw new Error('FLOW_STORAGE must be "local", "s3", "replit" or "off"');
443
+ if (mode === 'off') return { storage: null, mode, reason: 'off' };
444
+ if (mode === 'local') {
445
+ if (env.REPLIT_DEPLOYMENT) throw new Error('FLOW_STORAGE=local cannot be used on a published Replit app: a publish keeps no files');
446
+ const folder = root ?? env.FLOW_STORAGE_DIR ?? path.join('data', 'storage');
447
+ try {
448
+ const signingKey = configuredSigningKey(env, secrets) ?? await readOrCreateKey(folder);
449
+ return { storage: createLocalObjectStorage({ root: folder, signingKey }), mode, reason: null };
450
+ } catch (error) {
451
+ // The server image is read-only. An unset FLOW_STORAGE must not stop boot.
452
+ if (FOLDER_BLOCKED.has(error?.code)) {
453
+ if (!explicit) return { storage: null, mode: 'off', reason: 'local_not_writable' };
454
+ throw new Error('FLOW_STORAGE=local could not create its folder');
455
+ }
456
+ throw error;
457
+ }
458
+ }
459
+ if (mode === 's3') {
460
+ const bucket = String(env.FLOW_S3_BUCKET ?? '').trim();
461
+ const region = String(env.FLOW_S3_REGION ?? '').trim();
462
+ const accessKeyId = String(env.FLOW_S3_ACCESS_KEY_ID ?? '').trim();
463
+ const secretAccessKey = String(env.FLOW_S3_SECRET_ACCESS_KEY ?? '').trim();
464
+ if (!bucket || !region || !accessKeyId || !secretAccessKey) throw new Error('FLOW_STORAGE=s3 needs FLOW_S3_BUCKET, FLOW_S3_REGION, FLOW_S3_ACCESS_KEY_ID and FLOW_S3_SECRET_ACCESS_KEY');
465
+ return {
466
+ storage: createS3ObjectStorage({
467
+ bucket, region, accessKeyId, secretAccessKey,
468
+ endpoint: String(env.FLOW_S3_ENDPOINT ?? '').trim() || null,
469
+ forcePathStyle: String(env.FLOW_S3_FORCE_PATH_STYLE ?? '').trim().toLowerCase() === 'on',
470
+ }),
471
+ mode,
472
+ reason: null,
473
+ };
474
+ }
475
+ try {
476
+ const client = await loadReplitClient();
477
+ if (!client) {
478
+ if (explicit) throw new Error('FLOW_STORAGE=replit needs the @replit/object-storage package');
479
+ return { storage: null, mode, reason: 'replit_client_missing' };
480
+ }
481
+ const signingKey = configuredSigningKey(env, secrets);
482
+ if (!signingKey) {
483
+ if (explicit) throw new Error('FLOW_STORAGE=replit needs FLOW_SECRETS_KEY or FLOW_STORAGE_SIGNING_KEY');
484
+ return { storage: null, mode, reason: 'signing_key_missing' };
485
+ }
486
+ return { storage: createReplitObjectStorage({ client, signingKey }), mode, reason: null };
487
+ } catch (error) {
488
+ if (explicit) throw error;
489
+ return { storage: null, mode, reason: 'replit_unavailable' };
490
+ }
491
+ }
@@ -0,0 +1,46 @@
1
+ CREATE TABLE IF NOT EXISTS "captures" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "organisation_id" uuid NOT NULL,
4
+ "state" text NOT NULL,
5
+ "source" text NOT NULL,
6
+ "file_key" text NOT NULL,
7
+ "file_type" text NOT NULL,
8
+ "file_size" integer NOT NULL,
9
+ "file_sha256" text NOT NULL,
10
+ "idempotency_key" text NOT NULL,
11
+ "read" jsonb,
12
+ "match" jsonb,
13
+ "proposal" jsonb DEFAULT '{"action":null,"jobId":null,"changes":[]}'::jsonb NOT NULL,
14
+ "problem" text,
15
+ "decision" text,
16
+ "decided_by" uuid,
17
+ "decided_at" timestamp with time zone,
18
+ "photo_deleted_at" timestamp with time zone,
19
+ "created_by" uuid,
20
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
21
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
22
+ CONSTRAINT "captures_org_id" UNIQUE("organisation_id","id"),
23
+ CONSTRAINT "captures_org_idempotency" UNIQUE("organisation_id","idempotency_key"),
24
+ CONSTRAINT "captures_file_key" UNIQUE("file_key"),
25
+ CONSTRAINT "captures_state_valid" CHECK (state in ('reading', 'ready', 'done', 'problem')),
26
+ CONSTRAINT "captures_source_valid" CHECK (source in ('phone', 'desk', 'api')),
27
+ CONSTRAINT "captures_decision_valid" CHECK ("captures"."decision" is null or "captures"."decision" in ('applied', 'discarded')),
28
+ CONSTRAINT "captures_done_has_decision" CHECK (("captures"."state" = 'done') = ("captures"."decision" is not null)),
29
+ CONSTRAINT "captures_file_size_valid" CHECK ("captures"."file_size" >= 0),
30
+ CONSTRAINT "captures_sha256_valid" CHECK ("captures"."file_sha256" ~ '^[0-9a-f]{64}$'),
31
+ CONSTRAINT "captures_problem_valid" CHECK ("captures"."problem" is null or length("captures"."problem") between 1 and 200)
32
+ );
33
+ --> statement-breakpoint
34
+ DO $flow_guard$ BEGIN
35
+ ALTER TABLE "captures" ADD CONSTRAINT "captures_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "flow"."organisations"("id") ON DELETE no action ON UPDATE no action;
36
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
37
+ END $flow_guard$;--> statement-breakpoint
38
+ DO $flow_guard$ BEGIN
39
+ ALTER TABLE "captures" ADD CONSTRAINT "captures_decided_by_people_id_fk" FOREIGN KEY ("decided_by") REFERENCES "flow"."people"("id") ON DELETE set null ON UPDATE no action;
40
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
41
+ END $flow_guard$;--> statement-breakpoint
42
+ DO $flow_guard$ BEGIN
43
+ ALTER TABLE "captures" ADD CONSTRAINT "captures_created_by_people_id_fk" FOREIGN KEY ("created_by") REFERENCES "flow"."people"("id") ON DELETE set null ON UPDATE no action;
44
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
45
+ END $flow_guard$;--> statement-breakpoint
46
+ CREATE INDEX IF NOT EXISTS "captures_photo_open" ON "captures" USING btree ("created_at") WHERE photo_deleted_at is null;