@nacre.work/core 0.2.0 → 0.4.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 (59) hide show
  1. package/dist/authz/index.d.ts +0 -1
  2. package/dist/authz/index.d.ts.map +1 -1
  3. package/dist/authz/index.js +0 -1
  4. package/dist/authz/index.js.map +1 -1
  5. package/dist/config.d.ts +110 -4
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +347 -34
  8. package/dist/config.js.map +1 -1
  9. package/dist/db/migrate.d.ts +45 -0
  10. package/dist/db/migrate.d.ts.map +1 -1
  11. package/dist/db/migrate.js +61 -0
  12. package/dist/db/migrate.js.map +1 -1
  13. package/dist/extensions.d.ts +298 -0
  14. package/dist/extensions.d.ts.map +1 -0
  15. package/dist/extensions.js +246 -0
  16. package/dist/extensions.js.map +1 -0
  17. package/dist/index.d.ts +9 -5
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +5 -3
  20. package/dist/index.js.map +1 -1
  21. package/dist/metrics.d.ts +10 -7
  22. package/dist/metrics.d.ts.map +1 -1
  23. package/dist/metrics.js +25 -7
  24. package/dist/metrics.js.map +1 -1
  25. package/dist/migrations/0015_reference_queries.sql +97 -0
  26. package/dist/migrations/0016_drop_acl_tag_cache.sql +62 -0
  27. package/dist/migrations/0017_drop_workspace_admin_role.sql +30 -0
  28. package/dist/migrations/0018_group_members_nulls_not_distinct.sql +46 -0
  29. package/dist/migrations/0019_embedding_provider_write_guard.sql +58 -0
  30. package/dist/migrations/0020_document_content_type.sql +19 -0
  31. package/dist/multipart.d.ts +55 -0
  32. package/dist/multipart.d.ts.map +1 -0
  33. package/dist/multipart.js +158 -0
  34. package/dist/multipart.js.map +1 -0
  35. package/dist/oauth.d.ts +10 -0
  36. package/dist/oauth.d.ts.map +1 -1
  37. package/dist/oauth.js +10 -0
  38. package/dist/oauth.js.map +1 -1
  39. package/dist/observability.d.ts.map +1 -1
  40. package/dist/observability.js +24 -46
  41. package/dist/observability.js.map +1 -1
  42. package/dist/reindex.d.ts +44 -0
  43. package/dist/reindex.d.ts.map +1 -1
  44. package/dist/reindex.js +49 -0
  45. package/dist/reindex.js.map +1 -1
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/dist/vector/query.d.ts +7 -9
  48. package/dist/vector/query.d.ts.map +1 -1
  49. package/dist/vector/query.js +7 -6
  50. package/dist/vector/query.js.map +1 -1
  51. package/dist/vector/search.d.ts +49 -1
  52. package/dist/vector/search.d.ts.map +1 -1
  53. package/dist/vector/search.js +83 -1
  54. package/dist/vector/search.js.map +1 -1
  55. package/package.json +1 -1
  56. package/dist/authz/tags.d.ts +0 -40
  57. package/dist/authz/tags.d.ts.map +0 -1
  58. package/dist/authz/tags.js +0 -48
  59. package/dist/authz/tags.js.map +0 -1
@@ -0,0 +1,62 @@
1
+ -- 0016 — remove the ACL tag cache.
2
+ --
3
+ -- `docs/authz.md` 3.6 specified `acl_tags` in the vector payload as a second
4
+ -- filter alongside the layer bound: "both the layer filter and the tag filter
5
+ -- apply". The filter was never built. `buildFilter` is the only filter builder
6
+ -- in the codebase and it emits `org_id`, `deleted`, the plan's layers and
7
+ -- documents as a `should`, a `must_not` on denied documents, and the caller's
8
+ -- metadata narrowing — and no tag clause. So the whole subsystem kept a payload
9
+ -- field fresh that no query read.
10
+ --
11
+ -- Two things settled it, and only the second is decisive.
12
+ --
13
+ -- **It saves nothing.** The tags were justified as a speed-up over "joining
14
+ -- back to Postgres". Nothing joins back: the resolver computes the caller's
15
+ -- whole permitted set from `grants` on every request, so the expensive part has
16
+ -- already happened by the time a filter is built. A tag clause would be a
17
+ -- second constraint derived from the same table, one request later.
18
+ --
19
+ -- **It cannot express what the product now sells.** `tagsForLayer` read
20
+ -- `effect = 'allow'` grants on the layer and its workspace — document-scoped
21
+ -- grants were invisible to it, and so were denies. Applied as the `must` the
22
+ -- specification asks for, a caller reaching a document through a
23
+ -- document-scoped grant would have been filtered *out*: their principal is not
24
+ -- in that layer's tag set. Document-scoped grants are issuable now, through
25
+ -- `@nacre.work/enterprise-acl-advanced`, so the specified design and a shipped
26
+ -- feature could not both be true.
27
+ --
28
+ -- Making tags per-document would have fixed that and left the other cost: the
29
+ -- tag filter is stale by construction, so a *newly granted* document would stay
30
+ -- invisible until the sweep caught up. Revocation is already immediate — the
31
+ -- plan is computed per request, and the effective-principals cache is keyed on
32
+ -- `organizations.groups_version`, which bumps on every write to `grants` — so
33
+ -- the trade was a delay on grants in exchange for nothing on revocations.
34
+ --
35
+ -- What goes with it: the retag sweep, its lease, the propagation gauge and its
36
+ -- alert, `NACRE_ACL_TAG_HASH_BYTES` and `NACRE_ACL_PROPAGATION_SLA`. Invariant
37
+ -- I4 is unchanged and is now argued structurally rather than measured
38
+ -- temporally: there is no cache between a grant change and the next request.
39
+ --
40
+ -- `organizations.groups_version` stays. It is the effective-principals cache
41
+ -- key, which is read on every request, and its triggers are what make that
42
+ -- cache safe.
43
+
44
+ ALTER TABLE documents DROP COLUMN IF EXISTS acl_version;
45
+ ALTER TABLE documents DROP COLUMN IF EXISTS acl_tagged_at;
46
+
47
+ -- Dropped with its column. Named explicitly because a partial index over a
48
+ -- dropped column disappears silently, and a reader of 0004 should be able to
49
+ -- find where it went.
50
+ DROP INDEX IF EXISTS documents_acl_version_idx;
51
+
52
+ -- `sweep_claimed_at` **stays**, and 0011's name for it is now misleading rather
53
+ -- than wrong. It was added for the retag sweep and the collector took it as
54
+ -- well: `claimPurgeable` sets it to lease a tombstone so two workers do not
55
+ -- purge the same points, and `markPurged` clears it. Dropping it here removed a
56
+ -- column the collector reads — caught by the garbage-collection tests against a
57
+ -- real database, which is the only place it could have been caught.
58
+ COMMENT ON COLUMN documents.sweep_claimed_at IS
59
+ 'Lease held by the garbage collector while it purges a tombstone''s vectors. Cleared by markPurged, and reclaimed after NACRE_INDEX_LEASE if the worker holding it dies. Shared with the retag sweep until 0016 removed that sweep.';
60
+
61
+ COMMENT ON COLUMN organizations.groups_version IS
62
+ 'Permission epoch. Bumped by triggers on groups, group_members and grants. Read as the effective-principals cache key — which is what makes caching a permission input safe, since a change composes a different key rather than expiring an old one.';
@@ -0,0 +1,30 @@
1
+ -- 0017 — `users.role` loses `workspace_admin`.
2
+ --
3
+ -- 0001 wrote `CHECK (role IN ('platform_admin','org_admin','workspace_admin','member'))`
4
+ -- and `OrgRole` in the code has always been three values. A row carrying the
5
+ -- fourth read into a type that does not admit it and then behaved as `member`,
6
+ -- because it matches neither role branch in `resolve` and falls through to
7
+ -- grant-based evaluation. Safe — it denies rather than widens — and still a
8
+ -- role the schema offered, nothing implemented, and nothing refused: an
9
+ -- operator who set it got a silently demoted user and no error.
10
+ --
11
+ -- Removed rather than implemented, because it is a category error and not a
12
+ -- missing feature. `users.role` is organization-wide; "administers a
13
+ -- workspace" is scoped to one. The permission model already expresses it, and
14
+ -- expresses it better: `admin` on a `workspace` scope, which a person can hold
15
+ -- on several workspaces at once and which a single column cannot say. Adding
16
+ -- the role would have created a second way to spell one thing, with the
17
+ -- resolver having to decide which wins.
18
+ --
19
+ -- Existing rows are moved to `member`, which is what they already behaved as.
20
+ -- The UPDATE runs before the constraint is replaced, or the constraint would
21
+ -- refuse the rows it exists to remove.
22
+
23
+ UPDATE users SET role = 'member' WHERE role = 'workspace_admin';
24
+
25
+ ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
26
+ ALTER TABLE users ADD CONSTRAINT users_role_check
27
+ CHECK (role IN ('platform_admin', 'org_admin', 'member'));
28
+
29
+ COMMENT ON COLUMN users.role IS
30
+ 'Organization-wide role. Anything scoped to a workspace, a layer or a document is a grant, not a role — see docs/authz.md rules 2 and 3.';
@@ -0,0 +1,46 @@
1
+ -- 0018 — `group_members` can actually hold a member only once.
2
+ --
3
+ -- 0001 wrote `UNIQUE (group_id, member_user, member_group)` with a CHECK that
4
+ -- exactly one of the two member columns is non-null. **In Postgres a unique
5
+ -- index treats NULLs as distinct by default**, so two rows with the same
6
+ -- `group_id` and `member_user` and a NULL `member_group` do not conflict — and
7
+ -- every row this table holds has one of the two columns NULL, by construction.
8
+ --
9
+ -- The constraint therefore deduplicated nothing at all, in either direction.
10
+ -- `ON CONFLICT DO NOTHING` against it is a no-op, which is exactly how it was
11
+ -- found: a SCIM group sync adding the same member twice produced two rows and
12
+ -- an idempotent re-sync doubled a group's membership every time it ran.
13
+ --
14
+ -- Not a permission bug. `effectivePrincipals` walks the graph into a `Set`, so
15
+ -- a duplicated edge resolves to the same principal set and grants nothing
16
+ -- extra. What it is: a table that grows without bound under a directory sync,
17
+ -- a membership count that is wrong wherever one is shown, and — worse than
18
+ -- either — a uniqueness guarantee the schema appears to make and does not, so
19
+ -- the next writer reaches for `ON CONFLICT` and gets silence.
20
+ --
21
+ -- `NULLS NOT DISTINCT` needs Postgres 15; the Compose image is 17 and
22
+ -- docs/config.md has always named 17. Dropping to a pair of partial unique
23
+ -- indexes would work on older versions and is not worth carrying a version
24
+ -- floor nothing else needs.
25
+ --
26
+ -- Duplicates are collapsed before the constraint is replaced, or the constraint
27
+ -- refuses the rows it exists to prevent. `ctid` is the only thing that
28
+ -- distinguishes them, which is what "duplicate" means here.
29
+
30
+ DELETE FROM group_members a
31
+ USING group_members b
32
+ WHERE a.ctid > b.ctid
33
+ AND a.group_id = b.group_id
34
+ AND a.org_id = b.org_id
35
+ AND a.member_user IS NOT DISTINCT FROM b.member_user
36
+ AND a.member_group IS NOT DISTINCT FROM b.member_group;
37
+
38
+ ALTER TABLE group_members
39
+ DROP CONSTRAINT IF EXISTS group_members_group_id_member_user_member_group_key;
40
+
41
+ ALTER TABLE group_members
42
+ ADD CONSTRAINT group_members_member_unique
43
+ UNIQUE NULLS NOT DISTINCT (group_id, member_user, member_group);
44
+
45
+ COMMENT ON CONSTRAINT group_members_member_unique ON group_members IS
46
+ 'NULLS NOT DISTINCT, deliberately: exactly one of member_user and member_group is non-null on every row, so the default NULLS DISTINCT made this constraint match nothing.';
@@ -0,0 +1,58 @@
1
+ -- 0019 — a tenant may read the installation-wide embedding provider, never write it.
2
+ --
3
+ -- `embedding_providers` is the one table where a NULL `org_id` is legitimate: it
4
+ -- is the installation-wide default model, and 0002's `org_isolation` lets every
5
+ -- organization *read* it, on purpose. 0002 also carried this, two lines down:
6
+ --
7
+ -- -- Writing a global default is not something a tenant may do.
8
+ -- CREATE POLICY org_writes_own ON embedding_providers
9
+ -- FOR INSERT WITH CHECK (org_id = current_setting('app.current_org')::uuid);
10
+ --
11
+ -- and that policy does nothing. A PERMISSIVE policy is OR'd with the other
12
+ -- PERMISSIVE policy for the same command, so on INSERT the effective check is
13
+ -- `org_writes_own` OR `org_isolation` — and `org_isolation`'s check admits
14
+ -- `org_id IS NULL`. `org_writes_own` is a strict subset of what was already
15
+ -- allowed, so adding it changed nothing. It was verified inert against a real
16
+ -- database: a connection scoped to org A, as `nacre_app`, could INSERT, UPDATE
17
+ -- and DELETE the NULL-`org_id` row every other tenant reads as its default.
18
+ --
19
+ -- The consequence is the shape the whole schema's FORCE RLS exists to prevent,
20
+ -- one table over from where it was checked: repoint that endpoint and every
21
+ -- other tenant's documents are embedded by an attacker's service; delete it and
22
+ -- every tenant relying on the default stops resolving a model. No core endpoint
23
+ -- writes this table, so this is the second line of defence rather than a
24
+ -- reachable exploit today — but the second line is exactly what a NULL-`org_id`
25
+ -- row needs, because the first line (a handler setting `org_id` from the token)
26
+ -- cannot cover a row whose `org_id` is meant to be NULL.
27
+ --
28
+ -- The fix is RESTRICTIVE policies on the three writing commands. A RESTRICTIVE
29
+ -- policy is AND'd with the permissive result, so it can only ever remove access
30
+ -- — it cannot widen a read. SELECT carries none, so the global row stays
31
+ -- readable by every tenant exactly as before.
32
+
33
+ DROP POLICY IF EXISTS org_writes_own ON embedding_providers;
34
+
35
+ -- Each names the writing role's own organization and nothing else. `org_id`
36
+ -- NULL fails the equality (NULL = uuid is NULL, not true), which is what keeps
37
+ -- the global row out of a tenant's reach on every one of the three commands.
38
+ CREATE POLICY tenant_writes_own_insert ON embedding_providers
39
+ AS RESTRICTIVE FOR INSERT
40
+ WITH CHECK (org_id = current_setting('app.current_org')::uuid);
41
+
42
+ CREATE POLICY tenant_writes_own_update ON embedding_providers
43
+ AS RESTRICTIVE FOR UPDATE
44
+ USING (org_id = current_setting('app.current_org')::uuid)
45
+ WITH CHECK (org_id = current_setting('app.current_org')::uuid);
46
+
47
+ CREATE POLICY tenant_writes_own_delete ON embedding_providers
48
+ AS RESTRICTIVE FOR DELETE
49
+ USING (org_id = current_setting('app.current_org')::uuid);
50
+
51
+ -- The installation-wide default is still written once, by `init`, and it is a
52
+ -- NULL-`org_id` row — so it now goes in under `nacre_worker`, the same
53
+ -- BYPASSRLS role the worker's cross-tenant queue uses. `init` is the only
54
+ -- writer of that row (a per-organization provider carries the org's own id and
55
+ -- passes the policies above unchanged), which is why the exception is one
56
+ -- statement in one bootstrap command rather than a hole in the policy.
57
+ COMMENT ON POLICY tenant_writes_own_insert ON embedding_providers IS
58
+ 'A tenant writes only its own providers. The NULL-org_id installation default is written by init under nacre_worker; see 0019.';
@@ -0,0 +1,19 @@
1
+ -- 0020 — what the stored bytes are, recorded where the truth lives.
2
+ --
3
+ -- Binary ingest (docs/architecture.md, "Binary ingest") stores a PDF's bytes
4
+ -- in the bucket and needs the worker to know they are a PDF before it decides
5
+ -- how to hand them to the parser. The S3 object carries a Content-Type of its
6
+ -- own, but reading the answer from object metadata would make a bucket restore
7
+ -- load-bearing for correctness — and Postgres is the source of truth, which is
8
+ -- the whole restore doctrine. So the type is a column.
9
+ --
10
+ -- The default is the entire migration story: every existing document is UTF-8
11
+ -- text, because until this landed the edge refused anything else. No backfill,
12
+ -- no RLS change — the table's policies exist and a new column inherits them.
13
+
14
+ ALTER TABLE documents
15
+ ADD COLUMN content_type text NOT NULL DEFAULT 'text/plain'
16
+ CHECK (content_type IN ('text/plain', 'application/pdf'));
17
+
18
+ COMMENT ON COLUMN documents.content_type IS
19
+ 'What the stored bytes are. text/plain lives inline or in the bucket as UTF-8; application/pdf lives only in the bucket. The CHECK is the list of formats ingest accepts — extending it is a migration, deliberately.';
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `multipart/form-data`, parsed strictly.
3
+ *
4
+ * `POST /v1/documents` has declared this in `docs/openapi.yaml` since before
5
+ * there was a server, and `docs/api.md` has listed it as not built for just as
6
+ * long. This is it.
7
+ *
8
+ * ─── why by hand, and what that obliges ───
9
+ *
10
+ * This parses attacker-supplied bytes. The usual answer is a library, and the
11
+ * usual argument against one applies here more than anywhere else in this
12
+ * repository: the request path of a document index is exactly where a
13
+ * dependency's parser bugs become yours. The parser sidecar makes the same
14
+ * choice for the same reason and says so.
15
+ *
16
+ * The obligation that comes with it is to be strict rather than clever. Every
17
+ * bound below is a refusal, not a truncation, and none of them is
18
+ * configurable — a caller who trips one gets `400` and a reason:
19
+ *
20
+ * - the boundary must match RFC 2046's grammar, so it cannot carry a regular
21
+ * expression, a newline, or a thousand characters;
22
+ * - a bounded number of parts, so a body of ten thousand empty ones is a
23
+ * refusal rather than ten thousand allocations;
24
+ * - a bounded header block per part, so a part that is all headers cannot be
25
+ * scanned forever;
26
+ * - `filename` is read and never used to build anything. Object keys come from
27
+ * `documentKey`, which hashes the external id; a filename reaches a database
28
+ * column and nothing else.
29
+ *
30
+ * What it deliberately does not do: nested multipart, `Content-Transfer-
31
+ * Encoding`, and charsets other than UTF-8. Each is a real part of the
32
+ * specification and none is something a document upload needs, so each is a
33
+ * refusal rather than a branch.
34
+ */
35
+ export declare class MultipartError extends Error {
36
+ constructor(message: string);
37
+ }
38
+ export interface MultipartPart {
39
+ readonly name: string;
40
+ /** Present when the part carried one. Never used to build a path or a key. */
41
+ readonly filename?: string;
42
+ readonly contentType?: string;
43
+ readonly bytes: Uint8Array;
44
+ }
45
+ /** Enough for a document plus its fields, and small enough to bound the work. */
46
+ export declare const MAX_PARTS = 16;
47
+ /** A part's headers. Anything longer is not a header block, it is an attack. */
48
+ export declare const MAX_PART_HEADER_BYTES: number;
49
+ /**
50
+ * The boundary from a `content-type`, or `undefined` when this is not
51
+ * multipart at all — which is not an error, it is how a caller says "JSON".
52
+ */
53
+ export declare function multipartBoundary(contentType: string | undefined): string | undefined;
54
+ export declare function parseMultipart(body: Uint8Array, boundary: string): readonly MultipartPart[];
55
+ //# sourceMappingURL=multipart.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"multipart.d.ts","sourceRoot":"","sources":["../multipart.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,EAAE,MAAM;CAI5B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,8EAA8E;IAC9E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAA;CAC3B;AAED,iFAAiF;AACjF,eAAO,MAAM,SAAS,KAAK,CAAA;AAC3B,gFAAgF;AAChF,eAAO,MAAM,qBAAqB,QAAW,CAAA;AAK7C;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAqBrF;AAaD,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,aAAa,EAAE,CA+E3F"}
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `multipart/form-data`, parsed strictly.
3
+ *
4
+ * `POST /v1/documents` has declared this in `docs/openapi.yaml` since before
5
+ * there was a server, and `docs/api.md` has listed it as not built for just as
6
+ * long. This is it.
7
+ *
8
+ * ─── why by hand, and what that obliges ───
9
+ *
10
+ * This parses attacker-supplied bytes. The usual answer is a library, and the
11
+ * usual argument against one applies here more than anywhere else in this
12
+ * repository: the request path of a document index is exactly where a
13
+ * dependency's parser bugs become yours. The parser sidecar makes the same
14
+ * choice for the same reason and says so.
15
+ *
16
+ * The obligation that comes with it is to be strict rather than clever. Every
17
+ * bound below is a refusal, not a truncation, and none of them is
18
+ * configurable — a caller who trips one gets `400` and a reason:
19
+ *
20
+ * - the boundary must match RFC 2046's grammar, so it cannot carry a regular
21
+ * expression, a newline, or a thousand characters;
22
+ * - a bounded number of parts, so a body of ten thousand empty ones is a
23
+ * refusal rather than ten thousand allocations;
24
+ * - a bounded header block per part, so a part that is all headers cannot be
25
+ * scanned forever;
26
+ * - `filename` is read and never used to build anything. Object keys come from
27
+ * `documentKey`, which hashes the external id; a filename reaches a database
28
+ * column and nothing else.
29
+ *
30
+ * What it deliberately does not do: nested multipart, `Content-Transfer-
31
+ * Encoding`, and charsets other than UTF-8. Each is a real part of the
32
+ * specification and none is something a document upload needs, so each is a
33
+ * refusal rather than a branch.
34
+ */
35
+ export class MultipartError extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = 'MultipartError';
39
+ }
40
+ }
41
+ /** Enough for a document plus its fields, and small enough to bound the work. */
42
+ export const MAX_PARTS = 16;
43
+ /** A part's headers. Anything longer is not a header block, it is an attack. */
44
+ export const MAX_PART_HEADER_BYTES = 8 * 1024;
45
+ /** RFC 2046: 1-70 characters from a fixed set, not ending in a space. */
46
+ const BOUNDARY = /^[A-Za-z0-9'()+_,\-./:=?]([A-Za-z0-9'()+_,\-./:=? ]{0,68}[A-Za-z0-9'()+_,\-./:=?])?$/;
47
+ /**
48
+ * The boundary from a `content-type`, or `undefined` when this is not
49
+ * multipart at all — which is not an error, it is how a caller says "JSON".
50
+ */
51
+ export function multipartBoundary(contentType) {
52
+ if (contentType === undefined)
53
+ return undefined;
54
+ const [type, ...rest] = contentType.split(';');
55
+ if ((type ?? '').trim().toLowerCase() !== 'multipart/form-data')
56
+ return undefined;
57
+ for (const parameter of rest) {
58
+ const eq = parameter.indexOf('=');
59
+ if (eq === -1)
60
+ continue;
61
+ if (parameter.slice(0, eq).trim().toLowerCase() !== 'boundary')
62
+ continue;
63
+ let value = parameter.slice(eq + 1).trim();
64
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
65
+ value = value.slice(1, -1);
66
+ }
67
+ if (!BOUNDARY.test(value)) {
68
+ throw new MultipartError('the multipart boundary is not a valid one');
69
+ }
70
+ return value;
71
+ }
72
+ throw new MultipartError('multipart/form-data with no boundary parameter');
73
+ }
74
+ const CRLF = Buffer.from('\r\n');
75
+ const DOUBLE_CRLF = Buffer.from('\r\n\r\n');
76
+ /** `name="x"; filename="y"` — one parameter, unescaped the way browsers send it. */
77
+ function parameter(header, key) {
78
+ const match = new RegExp(`(?:^|;)\\s*${key}\\s*=\\s*("([^"]*)"|[^;]*)`, 'i').exec(header);
79
+ if (match === null)
80
+ return undefined;
81
+ const value = match[2] ?? match[1] ?? '';
82
+ return value.trim();
83
+ }
84
+ export function parseMultipart(body, boundary) {
85
+ const buffer = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
86
+ const delimiter = Buffer.from(`--${boundary}`);
87
+ let cursor = buffer.indexOf(delimiter);
88
+ if (cursor === -1)
89
+ throw new MultipartError('the body contains no multipart boundary');
90
+ const parts = [];
91
+ while (cursor !== -1) {
92
+ let start = cursor + delimiter.length;
93
+ // `--boundary--` closes the body. Anything after it is epilogue and
94
+ // ignored, which is what the specification says and what every client does.
95
+ if (buffer.slice(start, start + 2).toString() === '--')
96
+ break;
97
+ // The delimiter is followed by CRLF. Tolerating a bare LF here would mean
98
+ // accepting a body no conforming client sends, so it does not.
99
+ if (!buffer.slice(start, start + 2).equals(CRLF)) {
100
+ throw new MultipartError('a multipart boundary is not followed by CRLF');
101
+ }
102
+ start += 2;
103
+ const headerEnd = buffer.indexOf(DOUBLE_CRLF, start);
104
+ if (headerEnd === -1)
105
+ throw new MultipartError('a multipart part has no header block');
106
+ if (headerEnd - start > MAX_PART_HEADER_BYTES) {
107
+ throw new MultipartError('a multipart part has an implausibly large header block');
108
+ }
109
+ const headers = buffer.slice(start, headerEnd).toString('utf8');
110
+ const bodyStart = headerEnd + DOUBLE_CRLF.length;
111
+ const next = buffer.indexOf(delimiter, bodyStart);
112
+ if (next === -1)
113
+ throw new MultipartError('a multipart part is not terminated');
114
+ // The CRLF immediately before the next delimiter belongs to the delimiter,
115
+ // not to the content. Keeping it appends two bytes to every uploaded file.
116
+ const end = next >= 2 && buffer.slice(next - 2, next).equals(CRLF) ? next - 2 : next;
117
+ let disposition;
118
+ let contentType;
119
+ for (const line of headers.split('\r\n')) {
120
+ const colon = line.indexOf(':');
121
+ if (colon === -1)
122
+ continue;
123
+ const key = line.slice(0, colon).trim().toLowerCase();
124
+ const value = line.slice(colon + 1).trim();
125
+ if (key === 'content-disposition')
126
+ disposition = value;
127
+ else if (key === 'content-type')
128
+ contentType = value;
129
+ else if (key === 'content-transfer-encoding' && value.toLowerCase() !== 'binary') {
130
+ // Refused rather than decoded. base64 here is legal and archaic, and a
131
+ // decoder is another parser of hostile input for a case no browser and
132
+ // no HTTP client produces.
133
+ throw new MultipartError(`content-transfer-encoding ${value} is not supported`);
134
+ }
135
+ }
136
+ if (disposition === undefined)
137
+ throw new MultipartError('a multipart part has no content-disposition');
138
+ const name = parameter(disposition, 'name');
139
+ if (name === undefined || name === '') {
140
+ throw new MultipartError('a multipart part has no name');
141
+ }
142
+ const filename = parameter(disposition, 'filename');
143
+ parts.push({
144
+ name,
145
+ ...(filename === undefined || filename === '' ? {} : { filename }),
146
+ ...(contentType === undefined ? {} : { contentType }),
147
+ bytes: new Uint8Array(buffer.subarray(bodyStart, end)),
148
+ });
149
+ if (parts.length > MAX_PARTS) {
150
+ throw new MultipartError(`a multipart body may carry at most ${MAX_PARTS} parts`);
151
+ }
152
+ cursor = next;
153
+ }
154
+ if (parts.length === 0)
155
+ throw new MultipartError('the multipart body carries no parts');
156
+ return parts;
157
+ }
158
+ //# sourceMappingURL=multipart.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"multipart.js","sourceRoot":"","sources":["../multipart.ts"],"names":[],"mappings":"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"}
package/dist/oauth.d.ts CHANGED
@@ -36,6 +36,16 @@ export interface ProtectedResourceMetadata {
36
36
  }
37
37
  /** The path RFC 9728 fixes, and the one the `WWW-Authenticate` header names. */
38
38
  export declare const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
39
+ /**
40
+ * Where the public signing key is published — RFC 7517, and the conventional
41
+ * path every JWT library looks at first.
42
+ *
43
+ * Served only by a deployment that signs asymmetrically. With
44
+ * `NACRE_JWT_SECRET` there is nothing to publish and this answers `404`: a
45
+ * shared secret has no public half, and an endpoint that produced one anyway
46
+ * would be handing out the key that mints tokens.
47
+ */
48
+ export declare const JWKS_PATH = "/.well-known/jwks.json";
39
49
  export declare function protectedResourceMetadata(input: {
40
50
  /** `NACRE_CANONICAL_URL`. Baked into the issuer of every token ever minted. */
41
51
  readonly canonicalUrl: string;
@@ -1 +1 @@
1
- {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../oauth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,qBAAqB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAClD,QAAQ,CAAC,wBAAwB,EAAE,SAAS,MAAM,EAAE,CAAA;IACpD,QAAQ,CAAC,sBAAsB,EAAE,MAAM,CAAA;IACvC,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7C;AAED,gFAAgF;AAChF,eAAO,MAAM,uBAAuB,0CAA0C,CAAA;AAE9E,wBAAgB,yBAAyB,CAAC,KAAK,EAAE;IAC/C,+EAA+E;IAC/E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;CACtC,GAAG,yBAAyB,CAoB5B"}
1
+ {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../oauth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,qBAAqB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAClD,QAAQ,CAAC,wBAAwB,EAAE,SAAS,MAAM,EAAE,CAAA;IACpD,QAAQ,CAAC,sBAAsB,EAAE,MAAM,CAAA;IACvC,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7C;AAED,gFAAgF;AAChF,eAAO,MAAM,uBAAuB,0CAA0C,CAAA;AAE9E;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,2BAA2B,CAAA;AAEjD,wBAAgB,yBAAyB,CAAC,KAAK,EAAE;IAC/C,+EAA+E;IAC/E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;CACtC,GAAG,yBAAyB,CAoB5B"}
package/dist/oauth.js CHANGED
@@ -29,6 +29,16 @@
29
29
  */
30
30
  /** The path RFC 9728 fixes, and the one the `WWW-Authenticate` header names. */
31
31
  export const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource';
32
+ /**
33
+ * Where the public signing key is published — RFC 7517, and the conventional
34
+ * path every JWT library looks at first.
35
+ *
36
+ * Served only by a deployment that signs asymmetrically. With
37
+ * `NACRE_JWT_SECRET` there is nothing to publish and this answers `404`: a
38
+ * shared secret has no public half, and an endpoint that produced one anyway
39
+ * would be handing out the key that mints tokens.
40
+ */
41
+ export const JWKS_PATH = '/.well-known/jwks.json';
32
42
  export function protectedResourceMetadata(input) {
33
43
  return {
34
44
  // Without a trailing slash, because this is the audience value a token is
package/dist/oauth.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"oauth.js","sourceRoot":"","sources":["../oauth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAUH,gFAAgF;AAChF,MAAM,CAAC,MAAM,uBAAuB,GAAG,uCAAuC,CAAA;AAE9E,MAAM,UAAU,yBAAyB,CAAC,KAKzC;IACC,OAAO;QACL,0EAA0E;QAC1E,4EAA4E;QAC5E,0DAA0D;QAC1D,QAAQ,EAAE,KAAK,CAAC,YAAY,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC;QAChD,GAAG,CAAC,KAAK,CAAC,mBAAmB,KAAK,SAAS;YACzC,CAAC,CAAC,EAAE;YACJ,CAAC,CAAC,EAAE,qBAAqB,EAAE,CAAC,KAAK,CAAC,mBAAmB,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC;QAC/E,wEAAwE;QACxE,4EAA4E;QAC5E,sEAAsE;QACtE,wBAAwB,EAAE,CAAC,QAAQ,CAAC;QACpC,sBAAsB,EAAE,2DAA2D;QACnF,2EAA2E;QAC3E,yEAAyE;QACzE,qEAAqE;QACrE,oDAAoD;QACpD,gBAAgB,EAAE,EAAE;KACrB,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"oauth.js","sourceRoot":"","sources":["../oauth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAUH,gFAAgF;AAChF,MAAM,CAAC,MAAM,uBAAuB,GAAG,uCAAuC,CAAA;AAE9E;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,wBAAwB,CAAA;AAEjD,MAAM,UAAU,yBAAyB,CAAC,KAKzC;IACC,OAAO;QACL,0EAA0E;QAC1E,4EAA4E;QAC5E,0DAA0D;QAC1D,QAAQ,EAAE,KAAK,CAAC,YAAY,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC;QAChD,GAAG,CAAC,KAAK,CAAC,mBAAmB,KAAK,SAAS;YACzC,CAAC,CAAC,EAAE;YACJ,CAAC,CAAC,EAAE,qBAAqB,EAAE,CAAC,KAAK,CAAC,mBAAmB,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC;QAC/E,wEAAwE;QACxE,4EAA4E;QAC5E,sEAAsE;QACtE,wBAAwB,EAAE,CAAC,QAAQ,CAAC;QACpC,sBAAsB,EAAE,2DAA2D;QACnF,2EAA2E;QAC3E,yEAAyE;QACzE,qEAAqE;QACrE,oDAAoD;QACpD,gBAAgB,EAAE,EAAE;KACrB,CAAA;AACH,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"observability.d.ts","sourceRoot":"","sources":["../observability.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,IAAI,CAAA;AAI9B,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAE3C;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAkKtG"}
1
+ {"version":3,"file":"observability.d.ts","sourceRoot":"","sources":["../observability.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,IAAI,CAAA;AAI9B,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAE3C;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAyItG"}
@@ -15,10 +15,10 @@ 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
23
  metrics.collectionsRetired.reset();
24
24
  // One series per layer that has ever been reindexed, labelled by slug
@@ -76,62 +76,40 @@ export function collectDatabaseGauges(pool, metrics, role) {
76
76
  // still inside `withOrg`: collapsing across tenants would mean reading
77
77
  // every tenant's documents from the API process, and the row-level
78
78
  // policies are not something to route around for a gauge.
79
- 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,
80
87
  count(*) FILTER (WHERE deleted_at IS NULL)::text AS live,
81
88
  count(*) FILTER (
82
89
  WHERE deleted_at IS NOT NULL AND vectors_purged_at IS NULL
83
90
  )::text AS tombstoned,
84
- -- The oldest document whose tags predate the current
85
- -- groups_version. chunk_count > 0 is the whole
86
- -- correctness of this one, and it was missing once: the
87
- -- number is the age of the oldest document still
88
- -- carrying tags built from a superseded grant set, which
89
- -- only a document with points in the index can do.
90
- -- Without the clause a single failed or pending
91
- -- document pinned the gauge at its own age forever,
92
- -- because the retag loop claims neither and nothing else
93
- -- can clear it — the one metric with an alert on it
94
- -- became a stuck alert, which is how it gets muted.
95
- --
96
- -- Deliberately not status = 'indexed'. A document that
97
- -- indexed once and failed on a later pass still has the
98
- -- earlier pass's points and still carries their tags,
99
- -- and is exactly the case this is meant to catch.
100
- -- claimStale uses the same predicate; the two must
101
- -- agree, or the gauge counts what the loop cannot claim.
102
- min(COALESCE(acl_tagged_at, created_at)) FILTER (
103
- WHERE deleted_at IS NULL AND chunk_count > 0
104
- AND acl_version < $2
105
- )::text AS oldest_stale
91
+ extract(
92
+ epoch FROM max(now() - claimed_at) FILTER (WHERE claimed_at IS NOT NULL)
93
+ ) AS claim_age
106
94
  FROM documents
107
95
  WHERE org_id = $1
108
- GROUP BY status`, [org.id, Number(org.groups_version)])).rows, scope);
96
+ GROUP BY status`, [org.id])).rows, scope);
109
97
  let tombstoned = 0;
110
- let oldest;
98
+ let claimAge = 0;
111
99
  for (const row of rows) {
112
100
  metrics.documents.set(Number(row.live), { org: org.slug, status: row.status });
113
101
  tombstoned += Number(row.tombstoned);
114
- if (row.oldest_stale !== null) {
115
- const at = Date.parse(row.oldest_stale);
116
- if (!Number.isNaN(at) && (oldest === undefined || at < oldest))
117
- oldest = at;
118
- }
102
+ if (row.claim_age !== null)
103
+ claimAge = Math.max(claimAge, Number(row.claim_age));
119
104
  }
120
105
  metrics.tombstonesPending.set(tombstoned, { org: org.slug });
121
- // Per organization, and set even when it is zero.
122
- //
123
- // A single worst-across-tenants number was the first shape of this and
124
- // it was wrong twice over: one neglected tenant pins the gauge and hides
125
- // every other tenant behind it, and when the alert does fire it does not
126
- // say who. `max(nacre_acl_propagation_lag_seconds) > 60` is the same
127
- // alert and is still independent of how many tenants exist, so the
128
- // aggregate cost nothing and bought nothing.
129
- //
130
- // Zero is written rather than omitted: an absent series and a zero one
131
- // mean "caught up" and "not being measured", and those must not look
132
- // alike on the one metric that evidences I4.
133
- const lag = oldest === undefined ? 0 : Math.max(0, (Date.now() - oldest) / 1000);
134
- 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 });
135
113
  }
136
114
  }
137
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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"}