@nacre.work/core 0.1.0 → 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 (78) hide show
  1. package/dist/audit.d.ts +41 -0
  2. package/dist/audit.d.ts.map +1 -0
  3. package/dist/audit.js +18 -0
  4. package/dist/audit.js.map +1 -0
  5. package/dist/authz/cache.d.ts +20 -0
  6. package/dist/authz/cache.d.ts.map +1 -1
  7. package/dist/authz/cache.js +29 -0
  8. package/dist/authz/cache.js.map +1 -1
  9. package/dist/authz/index.d.ts +1 -2
  10. package/dist/authz/index.d.ts.map +1 -1
  11. package/dist/authz/index.js +1 -2
  12. package/dist/authz/index.js.map +1 -1
  13. package/dist/config.d.ts +132 -5
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +439 -22
  16. package/dist/config.js.map +1 -1
  17. package/dist/db/migrate.d.ts +45 -0
  18. package/dist/db/migrate.d.ts.map +1 -1
  19. package/dist/db/migrate.js +61 -0
  20. package/dist/db/migrate.js.map +1 -1
  21. package/dist/extensions.d.ts +298 -0
  22. package/dist/extensions.d.ts.map +1 -0
  23. package/dist/extensions.js +246 -0
  24. package/dist/extensions.js.map +1 -0
  25. package/dist/index.d.ts +16 -6
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +9 -4
  28. package/dist/index.js.map +1 -1
  29. package/dist/lifecycle.d.ts.map +1 -1
  30. package/dist/lifecycle.js +13 -26
  31. package/dist/lifecycle.js.map +1 -1
  32. package/dist/logging.d.ts +46 -0
  33. package/dist/logging.d.ts.map +1 -0
  34. package/dist/logging.js +110 -0
  35. package/dist/logging.js.map +1 -0
  36. package/dist/metrics.d.ts +11 -7
  37. package/dist/metrics.d.ts.map +1 -1
  38. package/dist/metrics.js +32 -7
  39. package/dist/metrics.js.map +1 -1
  40. package/dist/migrations/0014_retired_collections.sql +68 -0
  41. package/dist/migrations/0015_reference_queries.sql +97 -0
  42. package/dist/migrations/0016_drop_acl_tag_cache.sql +62 -0
  43. package/dist/migrations/0017_drop_workspace_admin_role.sql +30 -0
  44. package/dist/migrations/0018_group_members_nulls_not_distinct.sql +46 -0
  45. package/dist/migrations/0019_embedding_provider_write_guard.sql +58 -0
  46. package/dist/multipart.d.ts +55 -0
  47. package/dist/multipart.d.ts.map +1 -0
  48. package/dist/multipart.js +158 -0
  49. package/dist/multipart.js.map +1 -0
  50. package/dist/oauth.d.ts +10 -0
  51. package/dist/oauth.d.ts.map +1 -1
  52. package/dist/oauth.js +10 -0
  53. package/dist/oauth.js.map +1 -1
  54. package/dist/observability.d.ts.map +1 -1
  55. package/dist/observability.js +43 -46
  56. package/dist/observability.js.map +1 -1
  57. package/dist/reindex.d.ts +44 -0
  58. package/dist/reindex.d.ts.map +1 -1
  59. package/dist/reindex.js +49 -0
  60. package/dist/reindex.js.map +1 -1
  61. package/dist/s3.d.ts +111 -0
  62. package/dist/s3.d.ts.map +1 -0
  63. package/dist/s3.js +242 -0
  64. package/dist/s3.js.map +1 -0
  65. package/dist/tsconfig.tsbuildinfo +1 -1
  66. package/dist/vector/query.d.ts +7 -9
  67. package/dist/vector/query.d.ts.map +1 -1
  68. package/dist/vector/query.js +7 -6
  69. package/dist/vector/query.js.map +1 -1
  70. package/dist/vector/search.d.ts +86 -1
  71. package/dist/vector/search.d.ts.map +1 -1
  72. package/dist/vector/search.js +185 -1
  73. package/dist/vector/search.js.map +1 -1
  74. package/package.json +1 -1
  75. package/dist/authz/tags.d.ts +0 -40
  76. package/dist/authz/tags.d.ts.map +0 -1
  77. package/dist/authz/tags.js +0 -48
  78. package/dist/authz/tags.js.map +0 -1
@@ -15,11 +15,12 @@ export function collectDatabaseGauges(pool, metrics, role) {
15
15
  // Across organizations, so it runs outside withOrg under the role that
16
16
  // may see them all. Every query below names org_id in its output rather
17
17
  // than filtering to one.
18
- const { rows: orgs } = await client.query('SELECT id, slug, groups_version FROM organizations WHERE deleted_at IS NULL');
18
+ const { rows: orgs } = await client.query('SELECT id, slug FROM organizations WHERE deleted_at IS NULL');
19
19
  metrics.documents.reset();
20
20
  metrics.tombstonesPending.reset();
21
- metrics.aclPropagationLag.reset();
21
+ metrics.processingAge.reset();
22
22
  metrics.reindexProgress.reset();
23
+ metrics.collectionsRetired.reset();
23
24
  // One series per layer that has ever been reindexed, labelled by slug
24
25
  // like every other per-tenant gauge here. Layers with no reindex produce
25
26
  // no row and therefore no series: inventing a zero for them would mean
@@ -41,6 +42,24 @@ export function collectDatabaseGauges(pool, metrics, role) {
41
42
  continue;
42
43
  metrics.reindexProgress.set(reindexProgress(state), { org: row.slug, layer: row.layer });
43
44
  }
45
+ // Collections a finished migration is still holding, for the same reason
46
+ // and in the same shape as the reindex gauge above: one query across
47
+ // organizations, because `retired_collections` holds a handful of rows in
48
+ // total rather than a handful per tenant, and a scrape must not cost a
49
+ // query per organization.
50
+ //
51
+ // Only organizations that have one produce a series. A zero for everybody
52
+ // else would be true and useless — the number that matters is how much
53
+ // disk a completed migration has not given back, and the alertable shape
54
+ // is one that stops falling, which needs a series to exist at all rather
55
+ // than a floor under every tenant.
56
+ const { rows: retained } = await client.query(`SELECT o.slug, count(*) AS n
57
+ FROM retired_collections r
58
+ JOIN organizations o ON o.id = r.org_id
59
+ GROUP BY o.slug`);
60
+ for (const row of retained) {
61
+ metrics.collectionsRetired.set(Number(row.n), { org: row.slug });
62
+ }
44
63
  for (const org of orgs) {
45
64
  // One query per organization, not three.
46
65
  //
@@ -57,62 +76,40 @@ export function collectDatabaseGauges(pool, metrics, role) {
57
76
  // still inside `withOrg`: collapsing across tenants would mean reading
58
77
  // every tenant's documents from the API process, and the row-level
59
78
  // policies are not something to route around for a gauge.
60
- const rows = await withOrg(pool, org.id, async (c) => (await c.query(`SELECT status,
79
+ const rows = await withOrg(pool, org.id, async (c) => (await c.query(
80
+ // claim_age rides this query rather than adding one: claimed_at
81
+ // is on the same documents rows, non-null only while a document
82
+ // is being indexed, and cleared on success or failure. The max
83
+ // per status is the oldest in-flight document; the max across
84
+ // statuses is taken below, so a worker wedged in either
85
+ // `parsing` or `indexing` shows up.
86
+ `SELECT status,
61
87
  count(*) FILTER (WHERE deleted_at IS NULL)::text AS live,
62
88
  count(*) FILTER (
63
89
  WHERE deleted_at IS NOT NULL AND vectors_purged_at IS NULL
64
90
  )::text AS tombstoned,
65
- -- The oldest document whose tags predate the current
66
- -- groups_version. chunk_count > 0 is the whole
67
- -- correctness of this one, and it was missing once: the
68
- -- number is the age of the oldest document still
69
- -- carrying tags built from a superseded grant set, which
70
- -- only a document with points in the index can do.
71
- -- Without the clause a single failed or pending
72
- -- document pinned the gauge at its own age forever,
73
- -- because the retag loop claims neither and nothing else
74
- -- can clear it — the one metric with an alert on it
75
- -- became a stuck alert, which is how it gets muted.
76
- --
77
- -- Deliberately not status = 'indexed'. A document that
78
- -- indexed once and failed on a later pass still has the
79
- -- earlier pass's points and still carries their tags,
80
- -- and is exactly the case this is meant to catch.
81
- -- claimStale uses the same predicate; the two must
82
- -- agree, or the gauge counts what the loop cannot claim.
83
- min(COALESCE(acl_tagged_at, created_at)) FILTER (
84
- WHERE deleted_at IS NULL AND chunk_count > 0
85
- AND acl_version < $2
86
- )::text AS oldest_stale
91
+ extract(
92
+ epoch FROM max(now() - claimed_at) FILTER (WHERE claimed_at IS NOT NULL)
93
+ ) AS claim_age
87
94
  FROM documents
88
95
  WHERE org_id = $1
89
- GROUP BY status`, [org.id, Number(org.groups_version)])).rows, scope);
96
+ GROUP BY status`, [org.id])).rows, scope);
90
97
  let tombstoned = 0;
91
- let oldest;
98
+ let claimAge = 0;
92
99
  for (const row of rows) {
93
100
  metrics.documents.set(Number(row.live), { org: org.slug, status: row.status });
94
101
  tombstoned += Number(row.tombstoned);
95
- if (row.oldest_stale !== null) {
96
- const at = Date.parse(row.oldest_stale);
97
- if (!Number.isNaN(at) && (oldest === undefined || at < oldest))
98
- oldest = at;
99
- }
102
+ if (row.claim_age !== null)
103
+ claimAge = Math.max(claimAge, Number(row.claim_age));
100
104
  }
101
105
  metrics.tombstonesPending.set(tombstoned, { org: org.slug });
102
- // Per organization, and set even when it is zero.
103
- //
104
- // A single worst-across-tenants number was the first shape of this and
105
- // it was wrong twice over: one neglected tenant pins the gauge and hides
106
- // every other tenant behind it, and when the alert does fire it does not
107
- // say who. `max(nacre_acl_propagation_lag_seconds) > 60` is the same
108
- // alert and is still independent of how many tenants exist, so the
109
- // aggregate cost nothing and bought nothing.
110
- //
111
- // Zero is written rather than omitted: an absent series and a zero one
112
- // mean "caught up" and "not being measured", and those must not look
113
- // alike on the one metric that evidences I4.
114
- const lag = oldest === undefined ? 0 : Math.max(0, (Date.now() - oldest) / 1000);
115
- metrics.aclPropagationLag.set(lag, { org: org.slug });
106
+ // Only when something is in flight. A zero for every idle tenant would
107
+ // be true and useless; the alertable shape is a series that exists and
108
+ // climbs, so no claimed document means no series, the same way the
109
+ // reindex and retired-collection gauges stay silent about tenants with
110
+ // nothing to report.
111
+ if (claimAge > 0)
112
+ metrics.processingAge.set(claimAge, { org: org.slug });
116
113
  }
117
114
  }
118
115
  finally {
@@ -1 +1 @@
1
- {"version":3,"file":"observability.js","sourceRoot":"","sources":["../observability.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"observability.js","sourceRoot":"","sources":["../observability.ts"],"names":[],"mappings":"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"}
package/dist/reindex.d.ts CHANGED
@@ -59,6 +59,40 @@ export interface ReindexState {
59
59
  readonly done: number;
60
60
  readonly failed: number;
61
61
  readonly error?: string;
62
+ /**
63
+ * What the recall check scored, once it has run.
64
+ *
65
+ * Absent until then, and absent forever for a layer with no reference query
66
+ * set — that layer has no gate, and `finishReindexIfDone` says so by asking
67
+ * whether the set exists rather than by looking for this key.
68
+ */
69
+ readonly check?: ReindexCheck;
70
+ }
71
+ /**
72
+ * The verdict of the gate between a finished reindex and its switch.
73
+ *
74
+ * `scores` is keyed by reference query id and not by text. The text is the
75
+ * operator's own, so echoing it would break no rule — but a status endpoint and
76
+ * a jsonb column are both read by more people than wrote the set, and the
77
+ * endpoint that lists it is one join away.
78
+ */
79
+ export interface ReindexCheck {
80
+ /** The mean of `scores`, in [0, 1]. */
81
+ readonly recall: number;
82
+ readonly floor: number;
83
+ readonly passed: boolean;
84
+ readonly queries: number;
85
+ readonly scores: readonly {
86
+ readonly queryId: string;
87
+ readonly recall: number;
88
+ }[];
89
+ /**
90
+ * External ids in the set naming no live document. Any means `passed` is
91
+ * false whatever `recall` says: a stale reference set and a bad model are
92
+ * different problems, and scoring a missing document as a miss reports the
93
+ * first as the second.
94
+ */
95
+ readonly unresolved?: readonly string[];
62
96
  }
63
97
  /**
64
98
  * How far along, as a ratio, for `nacre_reindex_progress_ratio`.
@@ -75,6 +109,16 @@ export interface ReindexState {
75
109
  export declare function reindexProgress(state: ReindexState): number;
76
110
  /** The JSON shape stored in the column. Snake case, like every other jsonb here. */
77
111
  export declare function toStateJson(state: ReindexState): Record<string, unknown>;
112
+ /**
113
+ * The verdict as it is stored, snake case like the rest of the column.
114
+ *
115
+ * Its own function because the worker writes this key on its own — one
116
+ * `jsonb_set` rather than a rewrite of the whole state — and a second
117
+ * hand-written shape there is how the two would drift. `passed` is read back
118
+ * out of this object by the SQL predicate that performs the switch, so its name
119
+ * and its type are load-bearing rather than cosmetic.
120
+ */
121
+ export declare function toCheckJson(check: ReindexCheck): Record<string, unknown>;
78
122
  /**
79
123
  * Read it back, or `undefined` for a column that holds nothing usable.
80
124
  *
@@ -1 +1 @@
1
- {"version":3,"file":"reindex.d.ts","sourceRoot":"","sources":["../reindex.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,uFAAuF;AACvF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAA;IAClD;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,WAAW,CAAA;IACvC;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,4CAA4C;IAC5C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,kEAAkE;IAClE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,uEAAuE;IACvE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAQ3D;AAED,oFAAoF;AACpF,wBAAgB,WAAW,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAaxE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,YAAY,GAAG,SAAS,CAsBpE"}
1
+ {"version":3,"file":"reindex.d.ts","sourceRoot":"","sources":["../reindex.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,uFAAuF;AACvF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAA;IAClD;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,WAAW,CAAA;IACvC;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,4CAA4C;IAC5C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,kEAAkE;IAClE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,uEAAuE;IACvE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAA;CAC9B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,YAAY;IAC3B,uCAAuC;IACvC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,SAAS;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACjF;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACxC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAQ3D;AAED,oFAAoF;AACpF,wBAAgB,WAAW,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAcxE;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASxE;AAyBD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,YAAY,GAAG,SAAS,CA0BpE"}
package/dist/reindex.js CHANGED
@@ -60,6 +60,51 @@ export function toStateJson(state) {
60
60
  done: state.done,
61
61
  failed: state.failed,
62
62
  ...(state.error === undefined ? {} : { error: state.error }),
63
+ ...(state.check === undefined ? {} : { check: toCheckJson(state.check) }),
64
+ };
65
+ }
66
+ /**
67
+ * The verdict as it is stored, snake case like the rest of the column.
68
+ *
69
+ * Its own function because the worker writes this key on its own — one
70
+ * `jsonb_set` rather than a rewrite of the whole state — and a second
71
+ * hand-written shape there is how the two would drift. `passed` is read back
72
+ * out of this object by the SQL predicate that performs the switch, so its name
73
+ * and its type are load-bearing rather than cosmetic.
74
+ */
75
+ export function toCheckJson(check) {
76
+ return {
77
+ recall: check.recall,
78
+ floor: check.floor,
79
+ passed: check.passed,
80
+ queries: check.queries,
81
+ scores: check.scores.map((s) => ({ query_id: s.queryId, recall: s.recall })),
82
+ ...(check.unresolved === undefined ? {} : { unresolved: [...check.unresolved] }),
83
+ };
84
+ }
85
+ function fromCheckJson(raw) {
86
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
87
+ return undefined;
88
+ const o = raw;
89
+ if (typeof o.recall !== 'number' || typeof o.passed !== 'boolean')
90
+ return undefined;
91
+ const scores = Array.isArray(o.scores) ? o.scores : [];
92
+ return {
93
+ recall: o.recall,
94
+ floor: typeof o.floor === 'number' ? o.floor : 0,
95
+ passed: o.passed,
96
+ queries: typeof o.queries === 'number' ? o.queries : scores.length,
97
+ scores: scores.flatMap((s) => {
98
+ if (s === null || typeof s !== 'object')
99
+ return [];
100
+ const e = s;
101
+ if (typeof e.query_id !== 'string' || typeof e.recall !== 'number')
102
+ return [];
103
+ return [{ queryId: e.query_id, recall: e.recall }];
104
+ }),
105
+ ...(Array.isArray(o.unresolved) && o.unresolved.length > 0
106
+ ? { unresolved: o.unresolved.filter((u) => typeof u === 'string') }
107
+ : {}),
63
108
  };
64
109
  }
65
110
  /**
@@ -92,6 +137,10 @@ export function fromStateJson(raw) {
92
137
  done: typeof o.done === 'number' ? o.done : 0,
93
138
  failed: typeof o.failed === 'number' ? o.failed : 0,
94
139
  ...(typeof o.error === 'string' ? { error: o.error } : {}),
140
+ ...(() => {
141
+ const check = fromCheckJson(o.check);
142
+ return check === undefined ? {} : { check };
143
+ })(),
95
144
  };
96
145
  }
97
146
  //# sourceMappingURL=reindex.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"reindex.js","sourceRoot":"","sources":["../reindex.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"reindex.js","sourceRoot":"","sources":["../reindex.ts"],"names":[],"mappings":"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"}
package/dist/s3.d.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Object storage, signed by hand.
3
+ *
4
+ * `NACRE_S3_*` has been in `docs/config.md` since before there was a server and
5
+ * read by nothing — not used, not validated, so a wrong endpoint or a missing
6
+ * credential was silent while the `full` profile started a MinIO nobody talked
7
+ * to. This is the client that ends that.
8
+ *
9
+ * ─── why not the AWS SDK ───
10
+ *
11
+ * Four operations are needed: put, get, delete, head. `@aws-sdk/client-s3` is
12
+ * tens of megabytes and hundreds of transitive packages, in a container whose
13
+ * job is to read documents other people send it — which is the argument
14
+ * `metrics.ts` already makes for not taking prom-client. SigV4 is a documented
15
+ * algorithm with a published set of test vectors, it fits in this file, and it
16
+ * has no release cadence to track.
17
+ *
18
+ * The rule that comes with that choice: **anything here is verified against a
19
+ * real MinIO before it is believed**, because a signing bug produces a 403 that
20
+ * says nothing about which of the six inputs was wrong.
21
+ *
22
+ * ─── what is deliberately not here ───
23
+ *
24
+ * No multipart upload, so an object is one PUT and `NACRE_MAX_DOCUMENT_BYTES`
25
+ * bounds it. No listing, because nothing needs to enumerate a bucket and a
26
+ * paginated list is where this file would stop being small. No retries: the
27
+ * callers are the ingest queue and the collector, both of which already retry
28
+ * whole units of work and would otherwise retry twice.
29
+ */
30
+ export interface S3Options {
31
+ /** Including the scheme and port, e.g. `http://minio:9000`. No trailing slash. */
32
+ readonly endpoint: string;
33
+ readonly bucket: string;
34
+ readonly region: string;
35
+ readonly accessKey: string;
36
+ readonly secretKey: string;
37
+ /**
38
+ * `{endpoint}/{bucket}/{key}` rather than `{bucket}.{endpoint}/{key}`.
39
+ *
40
+ * True for MinIO and for anything reached by IP or through a name that is not
41
+ * a wildcard DNS entry, which is every self-hosted deployment. AWS itself
42
+ * wants it false.
43
+ */
44
+ readonly forcePathStyle: boolean;
45
+ }
46
+ export declare class S3Error extends Error {
47
+ readonly status: number;
48
+ readonly method: string;
49
+ readonly key: string;
50
+ constructor(status: number, method: string, key: string, body: string);
51
+ }
52
+ export declare class S3 {
53
+ #private;
54
+ constructor(options: S3Options);
55
+ get bucket(): string;
56
+ /** Store an object, overwriting whatever was there. */
57
+ put(key: string, body: Uint8Array, contentType?: string): Promise<void>;
58
+ /** Fetch an object. `undefined` when it is not there, which is not an error. */
59
+ get(key: string): Promise<Uint8Array | undefined>;
60
+ /**
61
+ * Remove an object. Absent is success.
62
+ *
63
+ * S3 answers `204` whether or not the key existed, and the caller — the
64
+ * collector, purging a deleted document — must be able to run twice.
65
+ */
66
+ remove(key: string): Promise<void>;
67
+ /**
68
+ * A URL that fetches one object, for a while, without a credential.
69
+ *
70
+ * Query-string SigV4 rather than the header form: the point is a link a
71
+ * client can follow, so everything that authenticates it has to be in the
72
+ * URL. `host` is the only signed header, and the payload is unsigned because
73
+ * a GET has none.
74
+ *
75
+ * **This is a bearer capability and it leaves the permission model behind.**
76
+ * Nacre checks `read` once, when the link is minted, and the object store
77
+ * checks nothing afterwards — a revocation during the window does not reach a
78
+ * URL already handed out, and neither does a delete. That is what presigning
79
+ * is, in every implementation of it, and it is why the lifetime is a
80
+ * configured number rather than a constant: `NACRE_PRESIGN_TTL` is how long a
81
+ * deployment is willing for that to be true.
82
+ *
83
+ * It is also why the caller of this decides, not this: minting one per search
84
+ * hit would issue ten capabilities to answer a question about relevance, most
85
+ * never followed. See the note on the search response.
86
+ */
87
+ presign(key: string, ttlSeconds: number): string;
88
+ /**
89
+ * Whether the bucket answers, for readiness.
90
+ *
91
+ * A HEAD on the bucket rather than on an object: it needs no key to exist and
92
+ * it fails for the three reasons that matter — unreachable, wrong bucket,
93
+ * wrong credential. A TCP connect would pass all three.
94
+ */
95
+ ready(): Promise<boolean>;
96
+ }
97
+ /**
98
+ * Where a document's bytes live.
99
+ *
100
+ * Derived from identity — organization, layer, external id — and never from the
101
+ * content. Content addressing would let two documents share an object, and then
102
+ * deleting one would either strand the other or need a reference count that
103
+ * nothing else in this system has. One document, one object, and a re-ingest
104
+ * overwrites in place.
105
+ *
106
+ * The external id is hashed rather than escaped: it is caller-chosen text that
107
+ * may contain anything, and a key derived by escaping is a key whose length and
108
+ * shape a caller controls.
109
+ */
110
+ export declare function documentKey(orgId: string, layerId: string, externalId: string): string;
111
+ //# sourceMappingURL=s3.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"s3.d.ts","sourceRoot":"","sources":["../s3.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,MAAM,WAAW,SAAS;IACxB,kFAAkF;IAClF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAA;CACjC;AA8BD,qBAAa,OAAQ,SAAQ,KAAK;IAE9B,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM;gBAFX,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACpB,IAAI,EAAE,MAAM;CAQf;AAED,qBAAa,EAAE;;gBAGD,OAAO,EAAE,SAAS;IAI9B,IAAI,MAAM,IAAI,MAAM,CAEnB;IAuGD,uDAAuD;IACjD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,WAAW,SAA6B,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjG,gFAAgF;IAC1E,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC;IAOvD;;;;;OAKG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOxC;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM;IA2ChD;;;;;;OAMG;IACG,KAAK,IAAI,OAAO,CAAC,OAAO,CAAC;CAiBhC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAEtF"}
package/dist/s3.js ADDED
@@ -0,0 +1,242 @@
1
+ import { createHash, createHmac } from 'node:crypto';
2
+ const UNSIGNED = 'UNSIGNED-PAYLOAD';
3
+ const ALGORITHM = 'AWS4-HMAC-SHA256';
4
+ const SERVICE = 's3';
5
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
6
+ const hmac = (key, data) => createHmac('sha256', key).update(data, 'utf8').digest();
7
+ /**
8
+ * Percent-encode one path segment the way SigV4 wants it.
9
+ *
10
+ * `encodeURIComponent` leaves `!'()*` alone and AWS does not, and it encodes
11
+ * nothing else differently — so this is that function plus five characters. A
12
+ * key containing any of them signs one way and is requested another, which is a
13
+ * 403 with no clue in it.
14
+ */
15
+ function encodeSegment(segment) {
16
+ return encodeURIComponent(segment).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
17
+ }
18
+ /** Keys are `a/b/c`; each segment is encoded and the separators are not. */
19
+ const encodeKey = (key) => key.split('/').map(encodeSegment).join('/');
20
+ export class S3Error extends Error {
21
+ status;
22
+ method;
23
+ key;
24
+ constructor(status, method, key, body) {
25
+ // The body, because S3 and MinIO both answer with an XML document naming
26
+ // the exact code — SignatureDoesNotMatch, NoSuchBucket, AccessDenied — and
27
+ // a status alone sends the reader to the wrong one of six inputs.
28
+ super(`s3 ${method} ${key} failed: ${status} ${body.slice(0, 512)}`);
29
+ this.status = status;
30
+ this.method = method;
31
+ this.key = key;
32
+ this.name = 'S3Error';
33
+ }
34
+ }
35
+ export class S3 {
36
+ #options;
37
+ constructor(options) {
38
+ this.#options = { ...options, endpoint: options.endpoint.replace(/\/+$/, '') };
39
+ }
40
+ get bucket() {
41
+ return this.#options.bucket;
42
+ }
43
+ #url(key) {
44
+ const { endpoint, bucket, forcePathStyle } = this.#options;
45
+ if (forcePathStyle)
46
+ return new URL(`${endpoint}/${bucket}/${encodeKey(key)}`);
47
+ const url = new URL(endpoint);
48
+ url.host = `${bucket}.${url.host}`;
49
+ url.pathname = `/${encodeKey(key)}`;
50
+ return url;
51
+ }
52
+ /**
53
+ * Sign a request, returning the headers to send with it.
54
+ *
55
+ * The payload hash goes in `x-amz-content-sha256` and into the canonical
56
+ * request, so a body that changes in flight fails the signature rather than
57
+ * being stored. `UNSIGNED-PAYLOAD` is used for GET and DELETE, which have
58
+ * none — hashing the empty string would also work and this is what every
59
+ * other implementation sends.
60
+ */
61
+ #sign(input) {
62
+ const { region, accessKey, secretKey } = this.#options;
63
+ const amzDate = input.now.toISOString().replace(/[-:]|\.\d{3}/g, '');
64
+ const dateStamp = amzDate.slice(0, 8);
65
+ const headers = {
66
+ ...input.headers,
67
+ host: input.url.host,
68
+ 'x-amz-content-sha256': input.payloadHash,
69
+ 'x-amz-date': amzDate,
70
+ };
71
+ // Lowercased, sorted, values trimmed — the canonical form. Getting the
72
+ // order wrong is a 403 that reads exactly like a wrong secret.
73
+ const canonicalHeaderNames = Object.keys(headers)
74
+ .map((h) => h.toLowerCase())
75
+ .sort();
76
+ const canonicalHeaders = canonicalHeaderNames
77
+ .map((name) => {
78
+ const key = Object.keys(headers).find((h) => h.toLowerCase() === name);
79
+ return `${name}:${String(headers[key]).trim()}\n`;
80
+ })
81
+ .join('');
82
+ const signedHeaders = canonicalHeaderNames.join(';');
83
+ const canonicalRequest = [
84
+ input.method,
85
+ input.url.pathname,
86
+ // No request here takes a query string. An empty one is still a line.
87
+ '',
88
+ canonicalHeaders,
89
+ signedHeaders,
90
+ input.payloadHash,
91
+ ].join('\n');
92
+ const scope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
93
+ const stringToSign = [ALGORITHM, amzDate, scope, sha256(canonicalRequest)].join('\n');
94
+ const signingKey = hmac(hmac(hmac(hmac(`AWS4${secretKey}`, dateStamp), region), SERVICE), 'aws4_request');
95
+ const signature = createHmac('sha256', signingKey).update(stringToSign, 'utf8').digest('hex');
96
+ return {
97
+ ...headers,
98
+ authorization: `${ALGORITHM} Credential=${accessKey}/${scope}, ` +
99
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`,
100
+ };
101
+ }
102
+ async #send(method, key, body, extraHeaders = {}) {
103
+ const url = this.#url(key);
104
+ const headers = this.#sign({
105
+ method,
106
+ url,
107
+ payloadHash: body === undefined ? UNSIGNED : sha256(body),
108
+ headers: body === undefined
109
+ ? extraHeaders
110
+ : { ...extraHeaders, 'content-length': String(body.length) },
111
+ now: new Date(),
112
+ });
113
+ return fetch(url, {
114
+ method,
115
+ headers,
116
+ ...(body === undefined ? {} : { body }),
117
+ });
118
+ }
119
+ /** Store an object, overwriting whatever was there. */
120
+ async put(key, body, contentType = 'application/octet-stream') {
121
+ const response = await this.#send('PUT', key, body, { 'content-type': contentType });
122
+ if (!response.ok)
123
+ throw new S3Error(response.status, 'PUT', key, await response.text());
124
+ }
125
+ /** Fetch an object. `undefined` when it is not there, which is not an error. */
126
+ async get(key) {
127
+ const response = await this.#send('GET', key);
128
+ if (response.status === 404)
129
+ return undefined;
130
+ if (!response.ok)
131
+ throw new S3Error(response.status, 'GET', key, await response.text());
132
+ return new Uint8Array(await response.arrayBuffer());
133
+ }
134
+ /**
135
+ * Remove an object. Absent is success.
136
+ *
137
+ * S3 answers `204` whether or not the key existed, and the caller — the
138
+ * collector, purging a deleted document — must be able to run twice.
139
+ */
140
+ async remove(key) {
141
+ const response = await this.#send('DELETE', key);
142
+ if (!response.ok && response.status !== 404) {
143
+ throw new S3Error(response.status, 'DELETE', key, await response.text());
144
+ }
145
+ }
146
+ /**
147
+ * A URL that fetches one object, for a while, without a credential.
148
+ *
149
+ * Query-string SigV4 rather than the header form: the point is a link a
150
+ * client can follow, so everything that authenticates it has to be in the
151
+ * URL. `host` is the only signed header, and the payload is unsigned because
152
+ * a GET has none.
153
+ *
154
+ * **This is a bearer capability and it leaves the permission model behind.**
155
+ * Nacre checks `read` once, when the link is minted, and the object store
156
+ * checks nothing afterwards — a revocation during the window does not reach a
157
+ * URL already handed out, and neither does a delete. That is what presigning
158
+ * is, in every implementation of it, and it is why the lifetime is a
159
+ * configured number rather than a constant: `NACRE_PRESIGN_TTL` is how long a
160
+ * deployment is willing for that to be true.
161
+ *
162
+ * It is also why the caller of this decides, not this: minting one per search
163
+ * hit would issue ten capabilities to answer a question about relevance, most
164
+ * never followed. See the note on the search response.
165
+ */
166
+ presign(key, ttlSeconds) {
167
+ const { region, accessKey, secretKey } = this.#options;
168
+ const url = this.#url(key);
169
+ const now = new Date();
170
+ const amzDate = now.toISOString().replace(/[-:]|\.\d{3}/g, '');
171
+ const dateStamp = amzDate.slice(0, 8);
172
+ const scope = `${dateStamp}/${region}/${SERVICE}/aws4_request`;
173
+ // Sorted by key, and encoded the same way the canonical request will be —
174
+ // a mismatch between the query that is signed and the query that is sent is
175
+ // a 403 with nothing in it about which of the two was wrong.
176
+ const params = [
177
+ ['X-Amz-Algorithm', ALGORITHM],
178
+ ['X-Amz-Credential', `${accessKey}/${scope}`],
179
+ ['X-Amz-Date', amzDate],
180
+ ['X-Amz-Expires', String(Math.max(1, Math.floor(ttlSeconds)))],
181
+ ['X-Amz-SignedHeaders', 'host'],
182
+ ];
183
+ const canonicalQuery = params
184
+ .map(([k, v]) => [encodeSegment(k), encodeSegment(v)])
185
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
186
+ .map(([k, v]) => `${k}=${v}`)
187
+ .join('&');
188
+ const canonicalRequest = [
189
+ 'GET',
190
+ url.pathname,
191
+ canonicalQuery,
192
+ `host:${url.host}\n`,
193
+ 'host',
194
+ UNSIGNED,
195
+ ].join('\n');
196
+ const stringToSign = [ALGORITHM, amzDate, scope, sha256(canonicalRequest)].join('\n');
197
+ const signingKey = hmac(hmac(hmac(hmac(`AWS4${secretKey}`, dateStamp), region), SERVICE), 'aws4_request');
198
+ const signature = createHmac('sha256', signingKey).update(stringToSign, 'utf8').digest('hex');
199
+ return `${url.origin}${url.pathname}?${canonicalQuery}&X-Amz-Signature=${signature}`;
200
+ }
201
+ /**
202
+ * Whether the bucket answers, for readiness.
203
+ *
204
+ * A HEAD on the bucket rather than on an object: it needs no key to exist and
205
+ * it fails for the three reasons that matter — unreachable, wrong bucket,
206
+ * wrong credential. A TCP connect would pass all three.
207
+ */
208
+ async ready() {
209
+ const url = this.#options.forcePathStyle
210
+ ? new URL(`${this.#options.endpoint}/${this.#options.bucket}`)
211
+ : this.#url('');
212
+ const headers = this.#sign({
213
+ method: 'HEAD',
214
+ url,
215
+ payloadHash: UNSIGNED,
216
+ headers: {},
217
+ now: new Date(),
218
+ });
219
+ const response = await fetch(url, { method: 'HEAD', headers });
220
+ if (!response.ok) {
221
+ throw new S3Error(response.status, 'HEAD', this.#options.bucket, response.statusText);
222
+ }
223
+ return true;
224
+ }
225
+ }
226
+ /**
227
+ * Where a document's bytes live.
228
+ *
229
+ * Derived from identity — organization, layer, external id — and never from the
230
+ * content. Content addressing would let two documents share an object, and then
231
+ * deleting one would either strand the other or need a reference count that
232
+ * nothing else in this system has. One document, one object, and a re-ingest
233
+ * overwrites in place.
234
+ *
235
+ * The external id is hashed rather than escaped: it is caller-chosen text that
236
+ * may contain anything, and a key derived by escaping is a key whose length and
237
+ * shape a caller controls.
238
+ */
239
+ export function documentKey(orgId, layerId, externalId) {
240
+ return `org/${orgId}/layer/${layerId}/${sha256(externalId)}`;
241
+ }
242
+ //# sourceMappingURL=s3.js.map