@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
@@ -0,0 +1,97 @@
1
+ -- The query set a reindex is checked against.
2
+ --
3
+ -- `docs/architecture.md` has carried a step 4 since before there was a reindex
4
+ -- — "the recall check against a reference query set" — and has listed it as not
5
+ -- built for just as long, with the reason: it is "a gate nobody can run without
6
+ -- a query set". This is the query set.
7
+ --
8
+ -- ─── what the gate is for ───
9
+ --
10
+ -- A migration onto a new model succeeds mechanically whether or not the new
11
+ -- model works. Every document gets a shadow vector, the count reaches zero,
12
+ -- `vector_name` moves, and search is now answered by the new slot. If the
13
+ -- provider was misconfigured — the right endpoint behind the wrong model name,
14
+ -- a truncated dimension, an embedder returning near-constant vectors — every
15
+ -- step still reports success and retrieval quietly collapses. There is no
16
+ -- failing test, no alert, and no error anywhere, because nothing failed.
17
+ --
18
+ -- The gate is the only thing in the sequence that asks whether the new model
19
+ -- can still answer, rather than whether the write happened.
20
+ --
21
+ -- ─── why ground truth rather than agreement with the old model ───
22
+ --
23
+ -- The cheaper design compares the new slot's answers against the old slot's and
24
+ -- gates on the overlap. It needs nothing from the operator, and it is wrong:
25
+ -- **a better model disagrees with the worse one it replaces**, so a gate on
26
+ -- agreement blocks exactly the migrations worth making, and passes a new model
27
+ -- that reproduces the old one's mistakes. What an operator means by "search
28
+ -- still works" is against documents they picked, which is a thing only they
29
+ -- have.
30
+ --
31
+ -- So the set is theirs and the gate is off until they write one. That is the
32
+ -- honest arrangement, and it is why this table exists rather than a constant.
33
+ --
34
+ -- ─── external ids, not document ids ───
35
+ --
36
+ -- `expected` names documents the way the operator does. A document deleted and
37
+ -- re-ingested is the same reference; a document id would have changed underneath
38
+ -- them silently and scored as a miss. The resolution to ids happens when the
39
+ -- check runs, and an entry that resolves to nothing **fails the check with that
40
+ -- reason** rather than counting as a miss — a stale reference set and a bad
41
+ -- model are different problems and must not produce the same number.
42
+
43
+ -- Composite keys for the cross-tenant foreign key below, the same shape 0002
44
+ -- gave `groups` and `users`. Without it a row could name a layer in one
45
+ -- organization while claiming another, and only the code writing the insert
46
+ -- would stop it.
47
+ ALTER TABLE layers ADD CONSTRAINT layers_id_org_key UNIQUE (id, org_id);
48
+
49
+ CREATE TABLE reference_queries (
50
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
51
+ org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
52
+ layer_id uuid NOT NULL,
53
+
54
+ -- Bounded because every one of these is an embedding round trip at the end
55
+ -- of a migration, and because an unbounded text column reached by an API is
56
+ -- a way to store something else.
57
+ query text NOT NULL CHECK (length(query) BETWEEN 1 AND 1024),
58
+
59
+ -- At most ten, which is the `k` the check retrieves at. A set naming more
60
+ -- expected documents than the check can return could never score 1.0, so
61
+ -- the floor would be unreachable and the operator would read it as a
62
+ -- regression. Refused here as well as at the API, because the two disagree
63
+ -- exactly when someone writes to the database by hand.
64
+ expected text[] NOT NULL CHECK (
65
+ cardinality(expected) BETWEEN 1 AND 10
66
+ AND array_position(expected, NULL) IS NULL
67
+ ),
68
+
69
+ -- The operator's order, preserved. A reference set is read as a list and a
70
+ -- report that renumbers it every time it is fetched is a report nobody can
71
+ -- compare against the last one.
72
+ ordinal int NOT NULL,
73
+ created_at timestamptz NOT NULL DEFAULT now(),
74
+
75
+ CONSTRAINT reference_queries_layer_in_org
76
+ FOREIGN KEY (layer_id, org_id) REFERENCES layers (id, org_id) ON DELETE CASCADE,
77
+ CONSTRAINT reference_queries_ordinal UNIQUE (layer_id, ordinal)
78
+ );
79
+
80
+ -- The two questions asked of this table: "the set for this layer" (the API, and
81
+ -- the check) and "does this layer have one at all" (the switch predicate).
82
+ CREATE INDEX reference_queries_layer ON reference_queries (org_id, layer_id);
83
+
84
+ -- Tenant data, so RLS, and FORCE because ENABLE alone does not apply to the
85
+ -- owner and migrations run as the owner.
86
+ ALTER TABLE reference_queries ENABLE ROW LEVEL SECURITY;
87
+ ALTER TABLE reference_queries FORCE ROW LEVEL SECURITY;
88
+
89
+ CREATE POLICY org_isolation ON reference_queries
90
+ USING (org_id = current_setting('app.current_org')::uuid);
91
+
92
+ -- `nacre_app` reads and rewrites the set, because the API does. `nacre_worker`
93
+ -- only reads: the check must not be able to alter the thing it is checked
94
+ -- against, and a sweep that could delete a reference query could turn a failing
95
+ -- gate into a passing one by emptying it.
96
+ GRANT SELECT, INSERT, DELETE ON reference_queries TO nacre_app;
97
+ GRANT SELECT ON reference_queries TO nacre_worker;
@@ -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,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,CA4ItG"}
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"}