@nacre.work/core 0.0.0 → 0.1.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 (72) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/dist/authz/filter.d.ts +54 -3
  4. package/dist/authz/filter.d.ts.map +1 -1
  5. package/dist/authz/filter.js +45 -9
  6. package/dist/authz/filter.js.map +1 -1
  7. package/dist/authz/index.d.ts +2 -2
  8. package/dist/authz/index.d.ts.map +1 -1
  9. package/dist/authz/index.js +1 -1
  10. package/dist/authz/index.js.map +1 -1
  11. package/dist/config.d.ts +33 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +137 -3
  14. package/dist/config.js.map +1 -1
  15. package/dist/db/client.d.ts +39 -0
  16. package/dist/db/client.d.ts.map +1 -1
  17. package/dist/db/client.js +82 -0
  18. package/dist/db/client.js.map +1 -1
  19. package/dist/db/migrate.d.ts.map +1 -1
  20. package/dist/db/migrate.js +49 -0
  21. package/dist/db/migrate.js.map +1 -1
  22. package/dist/index.d.ts +15 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/lifecycle.d.ts +57 -0
  27. package/dist/lifecycle.d.ts.map +1 -0
  28. package/dist/lifecycle.js +97 -0
  29. package/dist/lifecycle.js.map +1 -0
  30. package/dist/metadata.d.ts +50 -0
  31. package/dist/metadata.d.ts.map +1 -0
  32. package/dist/metadata.js +127 -0
  33. package/dist/metadata.js.map +1 -0
  34. package/dist/metrics.d.ts +18 -1
  35. package/dist/metrics.d.ts.map +1 -1
  36. package/dist/metrics.js +77 -2
  37. package/dist/metrics.js.map +1 -1
  38. package/dist/migrations/0008_authenticating_lookup.sql +124 -0
  39. package/dist/migrations/0009_refresh_tokens.sql +73 -0
  40. package/dist/migrations/0010_definer_search_path.sql +22 -0
  41. package/dist/migrations/0011_sweep_lease.sql +36 -0
  42. package/dist/migrations/0012_prune.sql +118 -0
  43. package/dist/migrations/0013_reindex.sql +65 -0
  44. package/dist/oauth.d.ts +45 -0
  45. package/dist/oauth.d.ts.map +1 -0
  46. package/dist/oauth.js +53 -0
  47. package/dist/oauth.js.map +1 -0
  48. package/dist/observability.d.ts.map +1 -1
  49. package/dist/observability.js +81 -13
  50. package/dist/observability.js.map +1 -1
  51. package/dist/passwords.d.ts +37 -0
  52. package/dist/passwords.d.ts.map +1 -0
  53. package/dist/passwords.js +234 -0
  54. package/dist/passwords.js.map +1 -0
  55. package/dist/redis.d.ts +46 -0
  56. package/dist/redis.d.ts.map +1 -0
  57. package/dist/redis.js +234 -0
  58. package/dist/redis.js.map +1 -0
  59. package/dist/reindex.d.ts +87 -0
  60. package/dist/reindex.d.ts.map +1 -0
  61. package/dist/reindex.js +97 -0
  62. package/dist/reindex.js.map +1 -0
  63. package/dist/tsconfig.tsbuildinfo +1 -1
  64. package/dist/vector/query.d.ts +49 -4
  65. package/dist/vector/query.d.ts.map +1 -1
  66. package/dist/vector/query.js +43 -10
  67. package/dist/vector/query.js.map +1 -1
  68. package/dist/vector/search.d.ts +109 -4
  69. package/dist/vector/search.d.ts.map +1 -1
  70. package/dist/vector/search.js +166 -7
  71. package/dist/vector/search.js.map +1 -1
  72. package/package.json +26 -8
@@ -0,0 +1,73 @@
1
+ -- Refresh tokens, for the login flow.
2
+ --
3
+ -- ─── stored hashed, like every other credential here ───
4
+ --
5
+ -- The column holds a SHA-256 of the token and never the token. A refresh token
6
+ -- outlives an access token by a month, so a database dump that contained them
7
+ -- would be a month of sessions rather than a list of identifiers.
8
+ --
9
+ -- ─── rotation, and what reuse means ───
10
+ --
11
+ -- Every refresh issues a new token and marks the old one used. A used token
12
+ -- presented again is not a client bug worth tolerating: the legitimate holder
13
+ -- has already exchanged it, so a second presentation means two parties hold the
14
+ -- same token, and one of them stole it. There is no way to tell which, so the
15
+ -- whole family is revoked and both are made to sign in again. Annoying once
16
+ -- beats a silent, indefinite session for whoever took it.
17
+ --
18
+ -- `family_id` is what makes that possible: it is the same value for every token
19
+ -- descended from one login, so revoking a family ends that session and no other.
20
+
21
+ CREATE TABLE refresh_tokens (
22
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
23
+ org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
24
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
25
+ -- Not unique across the table by accident: it is unique because the token
26
+ -- is 32 bytes from the CSPRNG, and the constraint says so rather than
27
+ -- leaving a collision to become two users sharing a session.
28
+ token_hash text NOT NULL UNIQUE,
29
+ family_id uuid NOT NULL,
30
+ issued_at timestamptz NOT NULL DEFAULT now(),
31
+ expires_at timestamptz NOT NULL,
32
+ used_at timestamptz,
33
+ revoked_at timestamptz
34
+ );
35
+
36
+ -- The lookup on every refresh: by hash, which is also the uniqueness above.
37
+ -- The family index is for revocation, which touches every row of one family.
38
+ CREATE INDEX refresh_tokens_family ON refresh_tokens (family_id) WHERE revoked_at IS NULL;
39
+ -- Expired rows are deleted by a sweep rather than kept forever; ordering by
40
+ -- expiry is what makes that sweep cheap.
41
+ CREATE INDEX refresh_tokens_expiry ON refresh_tokens (expires_at);
42
+
43
+ ALTER TABLE refresh_tokens ENABLE ROW LEVEL SECURITY;
44
+ ALTER TABLE refresh_tokens FORCE ROW LEVEL SECURITY;
45
+
46
+ -- Scoped like everything else. A refresh token names the organization it was
47
+ -- issued for, and the endpoint that redeems one has already resolved that
48
+ -- organization from the token's own row — through the authenticating policy
49
+ -- below, which is the same mechanism service account keys use and for the same
50
+ -- reason: the credential is what says which tenant it belongs to.
51
+ CREATE POLICY org_isolation ON refresh_tokens
52
+ USING (
53
+ CASE
54
+ WHEN current_setting('app.authenticating', true) = 'on' THEN false
55
+ ELSE org_id = current_setting('app.current_org')::uuid
56
+ END
57
+ );
58
+
59
+ CREATE POLICY authenticating ON refresh_tokens
60
+ FOR SELECT
61
+ USING (current_setting('app.authenticating', true) = 'on');
62
+
63
+ GRANT SELECT, INSERT, UPDATE, DELETE ON refresh_tokens TO nacre_app;
64
+ GRANT SELECT, INSERT, UPDATE, DELETE ON refresh_tokens TO nacre_worker;
65
+
66
+ -- ─── the password column gets a check it never had ───
67
+ --
68
+ -- `users.password_hash` has existed since 0001 and nothing has ever written to
69
+ -- it. Now that something does, an empty string must not be storable: it is
70
+ -- falsy in application code and truthy in SQL, which is exactly the shape of
71
+ -- difference that becomes an account anyone can sign into.
72
+ ALTER TABLE users ADD CONSTRAINT users_password_hash_not_blank
73
+ CHECK (password_hash IS NULL OR length(password_hash) >= 16);
@@ -0,0 +1,22 @@
1
+ -- Pin the search_path on the one SECURITY DEFINER function.
2
+ --
3
+ -- `0003` made `bump_groups_version()` SECURITY DEFINER and did not pin its
4
+ -- search_path. The body's unqualified `organizations` therefore resolves
5
+ -- through the *caller's* search_path, and any role may create objects in
6
+ -- `pg_temp`. A caller who shadows the table stops `groups_version` advancing —
7
+ -- so revocations never propagate, while `nacre_acl_propagation_lag_seconds`
8
+ -- reports a healthy zero because nothing is behind a version that never moved.
9
+ -- A silent failure of invariant 4, with its own evidence metric confirming
10
+ -- health.
11
+ --
12
+ -- It runs as the migration owner, which `docs/config.md` allows to be a
13
+ -- superuser, so the ceiling is high. Reaching it needs database access as
14
+ -- `nacre_app` — defence in depth rather than a remote hole — which is exactly
15
+ -- what the second line of defence is for in a product whose first line is a
16
+ -- permission model.
17
+ --
18
+ -- A separate migration rather than an edit to 0003: migrations are
19
+ -- forward-only and their checksums are re-verified on every run, so editing an
20
+ -- applied file makes the next `migrate` fail on every existing database.
21
+
22
+ ALTER FUNCTION bump_groups_version() SET search_path = pg_catalog, public;
@@ -0,0 +1,36 @@
1
+ -- A lease on the two background sweeps, so more workers do more work.
2
+ --
3
+ -- `claimStale` (retag) and `claimPurgeable` (garbage collection) both selected
4
+ -- the oldest N rows with no lock and no claim marker. Every replica therefore
5
+ -- selected the **same** N rows and did the same work: throughput stayed at one
6
+ -- worker's rate however many were running, while the load on Qdrant multiplied
7
+ -- by the replica count.
8
+ --
9
+ -- That matters beyond efficiency. The documented response to a climbing
10
+ -- `nacre_acl_propagation_lag_seconds` is to look at the worker — and scaling it
11
+ -- out, the obvious move, did nothing at all.
12
+ --
13
+ -- `FOR UPDATE SKIP LOCKED` alone does not fix it. The lock lives until the
14
+ -- transaction commits, and these transactions end the moment the SELECT
15
+ -- returns — the actual work (a Qdrant round trip) happens afterwards, outside
16
+ -- any transaction. So two replicas polling a second apart still collide.
17
+ --
18
+ -- ─── one column, because the two states are mutually exclusive ───
19
+ --
20
+ -- A document is either live and behind its organization's version (retag), or
21
+ -- deleted and not yet purged (garbage collection). `deleted_at IS NULL` decides
22
+ -- which, and nothing is ever both, so one claim column serves both sweeps.
23
+ --
24
+ -- Leased rather than owned, exactly like `documents.claimed_at`: a worker that
25
+ -- dies mid-sweep must not park a row forever. `NACRE_INDEX_LEASE` bounds it,
26
+ -- and the claim is only ever an optimisation — both sweeps are idempotent, and
27
+ -- `markTagged`'s version guard and `markPurged`'s `vectors_purged_at IS NULL`
28
+ -- guard remain the things that make a double pass harmless.
29
+
30
+ ALTER TABLE documents ADD COLUMN sweep_claimed_at timestamptz;
31
+
32
+ -- Partial, and on the claim rather than on the whole table: the sweeps read
33
+ -- "unclaimed, or claimed long enough ago to be abandoned", which is a small
34
+ -- slice of a large table.
35
+ CREATE INDEX documents_sweep_claim ON documents (sweep_claimed_at)
36
+ WHERE sweep_claimed_at IS NOT NULL;
@@ -0,0 +1,118 @@
1
+ -- Two things that grew forever, and the decision about the second one.
2
+ --
3
+ -- ─── refresh tokens ───
4
+ --
5
+ -- `0009` said, in a comment on the index built for it: "Expired rows are
6
+ -- deleted by a sweep rather than kept forever; ordering by expiry is what makes
7
+ -- that sweep cheap." There was no sweep. Every login and every rotation inserts
8
+ -- a row and nothing has ever removed one, so the table grows at the rate people
9
+ -- sign in — and grows fastest on the deployments that rotate most often, which
10
+ -- is to say the ones following the advice.
11
+ --
12
+ -- Nothing schema-side is needed for that: `nacre_worker` already holds DELETE
13
+ -- on `refresh_tokens` from `0009`. The sweep is in the worker. It is mentioned
14
+ -- here because this file is where somebody will come looking for it.
15
+ --
16
+ -- ─── the audit log, which is the actual decision ───
17
+ --
18
+ -- `NACRE_AUDIT_RETENTION_DAYS` has been validated at startup and read by
19
+ -- nothing since it was written, and `docs/audit.md` recorded why: `0002`
20
+ -- revokes DELETE on `audit_events` from `nacre_app` on purpose, so retention
21
+ -- "has to run as the owner. That is a decision still to be made rather than a
22
+ -- line still to be written."
23
+ --
24
+ -- This is the decision. Retention is implemented, and the append-only guarantee
25
+ -- is not weakened, because the two are not in tension once you say what
26
+ -- append-only is protecting: it stops history being *rewritten* — a specific
27
+ -- inconvenient event erased, a result flipped from deny to allow. Expiring
28
+ -- everything older than a published horizon is the opposite of that. It is
29
+ -- indiscriminate by construction, it is announced in the configuration, and an
30
+ -- auditor can compute exactly which window survives.
31
+ --
32
+ -- So the grant stays revoked and the pruning goes through one SECURITY DEFINER
33
+ -- function whose *shape* is the guarantee, because its shape is the only part
34
+ -- that holds against every caller:
35
+ --
36
+ -- * it takes a retention in days, never a predicate, so no caller can name a
37
+ -- row, an actor, an organization, or a result;
38
+ -- * it refuses a retention below 30 days, so it cannot be turned into "delete
39
+ -- everything" or "delete this morning";
40
+ -- * it deletes at most `max_rows` per call, so retention on a long-neglected
41
+ -- table is many small transactions rather than one that holds locks for an
42
+ -- hour;
43
+ -- * it returns the count, so the worker can log what it did and a run that
44
+ -- deleted a surprising number is visible rather than silent.
45
+ --
46
+ -- ─── what the EXECUTE grant below does and does not buy ───
47
+ --
48
+ -- It states intent and it stops third parties; it does **not** fence the
49
+ -- application off, and claiming otherwise here would be worse than not saying
50
+ -- it. `0008` grants `nacre_worker` to `nacre_app`, and membership inherits by
51
+ -- default, so the API's role can call this function without so much as a
52
+ -- SET ROLE. Verified rather than assumed — `SET ROLE nacre_app;
53
+ -- SELECT prune_audit_events(400, 10)` succeeds.
54
+ --
55
+ -- That is fine, and it is fine for a reason worth writing down: the four
56
+ -- properties above do not depend on who is calling. `nacre_app` reaching this
57
+ -- function can expire a window past a published horizon, which the operator
58
+ -- configured and an auditor can compute — and it still cannot erase a chosen
59
+ -- event, which is the whole content of the append-only guarantee. Its direct
60
+ -- DELETE stays revoked, so the arbitrary path remains closed.
61
+ --
62
+ -- Anyone with the owner's credentials can still do anything at all. That was
63
+ -- already true and no function changes it.
64
+
65
+ -- 30 days. Below this, retention stops being retention and becomes a way to
66
+ -- make recent events go away, which is the thing append-only exists to prevent.
67
+ -- Deliberately not configurable: a floor an operator can lower is not a floor.
68
+ CREATE FUNCTION prune_audit_events(retention_days integer, max_rows integer)
69
+ RETURNS integer
70
+ LANGUAGE plpgsql
71
+ SECURITY DEFINER
72
+ -- Pinned, for the reason 0010 exists: an unqualified name in a definer function
73
+ -- resolves through the caller's search_path, and any role may create objects in
74
+ -- pg_temp. A shadowed `audit_events` here would be a caller choosing which rows
75
+ -- a privileged function deletes.
76
+ SET search_path = pg_catalog, public
77
+ AS $$
78
+ DECLARE
79
+ removed integer;
80
+ BEGIN
81
+ IF retention_days < 30 THEN
82
+ RAISE EXCEPTION 'retention below the 30 day floor: %', retention_days
83
+ USING ERRCODE = 'invalid_parameter_value';
84
+ END IF;
85
+ IF max_rows < 1 OR max_rows > 100000 THEN
86
+ RAISE EXCEPTION 'max_rows out of range: %', max_rows
87
+ USING ERRCODE = 'invalid_parameter_value';
88
+ END IF;
89
+
90
+ -- By ctid over a bounded, ordered subquery: the index is
91
+ -- (org_id, occurred_at DESC), so an unbounded `DELETE ... WHERE
92
+ -- occurred_at < cutoff` on a table nobody has pruned in a year is one
93
+ -- statement scanning the lot and holding every row lock it takes until it
94
+ -- commits. Oldest first so repeated calls converge.
95
+ WITH doomed AS (
96
+ SELECT ctid
97
+ FROM audit_events
98
+ WHERE occurred_at < now() - make_interval(days => retention_days)
99
+ ORDER BY occurred_at
100
+ LIMIT max_rows
101
+ )
102
+ DELETE FROM audit_events a USING doomed d WHERE a.ctid = d.ctid;
103
+
104
+ GET DIAGNOSTICS removed = ROW_COUNT;
105
+ RETURN removed;
106
+ END
107
+ $$;
108
+
109
+ -- The worker, and only the worker. `nacre_app` is the role on the process that
110
+ -- serves requests; it has no business pruning anything, and 0002 revoked its
111
+ -- DELETE for exactly that reason.
112
+ REVOKE ALL ON FUNCTION prune_audit_events(integer, integer) FROM PUBLIC;
113
+ GRANT EXECUTE ON FUNCTION prune_audit_events(integer, integer) TO nacre_worker;
114
+
115
+ -- Expiry alone is not enough to find the sweep's rows cheaply once a family has
116
+ -- been revoked for a month and a half: 0009's index is on `expires_at`, which
117
+ -- is what the sweep orders by, and that one already exists. Nothing further is
118
+ -- needed here — noted so the next person does not add a redundant index.
@@ -0,0 +1,65 @@
1
+ -- Reindexing a layer onto a different embedding model.
2
+ --
3
+ -- `layers.reindex_state` has been in the schema since 0001 with a comment
4
+ -- describing its shape, and nothing has ever written it. This is what writes it,
5
+ -- plus the one column the pass needs to know what is left to do.
6
+ --
7
+ -- ─── what a reindex is, mechanically ───
8
+ --
9
+ -- Chunks already hold their text and their Qdrant point id. So reindexing is
10
+ -- not re-parsing anything: it is reading stored chunk text, embedding it with
11
+ -- the new model, and adding a second **named vector** to the points that are
12
+ -- already there. The old vector stays where it is and search keeps using it,
13
+ -- which is what "search stays available throughout" in docs/architecture.md
14
+ -- means in practice.
15
+ --
16
+ -- When every live document in the layer carries the new vector, `vector_name`
17
+ -- switches in one statement and the next query uses it.
18
+ --
19
+ -- ─── one column, and why not dual-write ───
20
+ --
21
+ -- `reindexed_vector` records which named vector a document has most recently
22
+ -- been reindexed onto. The pass selects documents in a reindexing layer where
23
+ -- it is distinct from the shadow vector; the switch happens when that set is
24
+ -- empty.
25
+ --
26
+ -- docs/architecture.md step 2 says "put the layer in dual-write: new documents
27
+ -- are indexed by both models". This does not do that, and the deviation is
28
+ -- deliberate. Dual-write means the ingest path carries reindex awareness
29
+ -- permanently — a branch on layer state in the hot path, for a condition that
30
+ -- is true for a few hours in a layer's life. Selecting on "lacks the shadow
31
+ -- vector" gets the same guarantee out of machinery that already exists: a
32
+ -- document ingested mid-reindex simply has no shadow vector yet, so the same
33
+ -- pass picks it up, and the switch cannot happen while it is outstanding.
34
+ --
35
+ -- The resulting invariant is stronger and easier to check than dual-write's:
36
+ -- **`vector_name` only ever changes when no live document in the layer lacks
37
+ -- the new vector.** One predicate, verifiable in SQL, rather than a promise
38
+ -- about two write paths staying in step.
39
+
40
+ ALTER TABLE documents ADD COLUMN reindexed_vector text;
41
+
42
+ -- Partial and on the layer, because the pass always asks the same question:
43
+ -- "in this layer, which documents do not yet carry this vector". Live documents
44
+ -- with chunks are the only ones that can — the rest have no points to add a
45
+ -- vector to.
46
+ CREATE INDEX documents_reindex_pending ON documents (layer_id, reindexed_vector)
47
+ WHERE deleted_at IS NULL AND chunk_count > 0;
48
+
49
+ -- ─── the state, and the one shape it is allowed to take ───
50
+ --
51
+ -- A check constraint rather than a convention. `reindex_state` is jsonb, which
52
+ -- means anything at all fits in the column, and the failure mode of a free-form
53
+ -- status field is a typo that reads as "not running" — leaving a reindex that
54
+ -- has stopped looking exactly like one that never started.
55
+ --
56
+ -- NULL means no reindex has ever run, which is different from one that finished
57
+ -- and is why the column is nullable rather than defaulted.
58
+ ALTER TABLE layers ADD CONSTRAINT layers_reindex_state_shape CHECK (
59
+ reindex_state IS NULL
60
+ OR (
61
+ reindex_state ? 'status'
62
+ AND reindex_state ->> 'status' IN ('running', 'complete', 'failed')
63
+ AND reindex_state ? 'shadow_vector'
64
+ )
65
+ );
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Protected resource metadata — RFC 9728.
3
+ *
4
+ * Every `401` from the MCP transport carries
5
+ * `WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"`,
6
+ * and for as long as that header existed nothing served the path it named. A
7
+ * client doing exactly what the header told it to got a `404`, which is the
8
+ * same failure as a search parameter read by nothing: the server described a
9
+ * capability and did not have it.
10
+ *
11
+ * ## This is a resource server, not an authorization server
12
+ *
13
+ * `docs/mcp.md` says so and the code agrees: tokens are verified locally
14
+ * against a key, and nothing here issues one through an OAuth flow.
15
+ * `/v1/auth/login` is email and password, and a service account key is a random
16
+ * string matched against a hash — neither is an OAuth grant.
17
+ *
18
+ * So `authorization_servers` is **absent unless an operator names one**. The
19
+ * field is optional in RFC 9728, and an installation that has not put an
20
+ * identity provider in front of Nacre has no authorization server to name.
21
+ * Pointing the field at ourselves would be the same lie the 404 was, one
22
+ * redirect further along: a client would fetch our metadata, learn we are our
23
+ * own issuer, ask us for a token endpoint, and find nothing again.
24
+ *
25
+ * What the document is still worth without that field is the part clients
26
+ * actually need first: the canonical resource identifier every token must be
27
+ * audience-bound to, and where to read about the credential that does work
28
+ * today.
29
+ */
30
+ export interface ProtectedResourceMetadata {
31
+ readonly resource: string;
32
+ readonly authorization_servers?: readonly string[];
33
+ readonly bearer_methods_supported: readonly string[];
34
+ readonly resource_documentation: string;
35
+ readonly scopes_supported: readonly string[];
36
+ }
37
+ /** The path RFC 9728 fixes, and the one the `WWW-Authenticate` header names. */
38
+ export declare const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
39
+ export declare function protectedResourceMetadata(input: {
40
+ /** `NACRE_CANONICAL_URL`. Baked into the issuer of every token ever minted. */
41
+ readonly canonicalUrl: string;
42
+ /** `NACRE_OAUTH_AUTHORIZATION_SERVER`, when a deployment has one. */
43
+ readonly authorizationServer?: string;
44
+ }): ProtectedResourceMetadata;
45
+ //# sourceMappingURL=oauth.d.ts.map
@@ -0,0 +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"}
package/dist/oauth.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Protected resource metadata — RFC 9728.
3
+ *
4
+ * Every `401` from the MCP transport carries
5
+ * `WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"`,
6
+ * and for as long as that header existed nothing served the path it named. A
7
+ * client doing exactly what the header told it to got a `404`, which is the
8
+ * same failure as a search parameter read by nothing: the server described a
9
+ * capability and did not have it.
10
+ *
11
+ * ## This is a resource server, not an authorization server
12
+ *
13
+ * `docs/mcp.md` says so and the code agrees: tokens are verified locally
14
+ * against a key, and nothing here issues one through an OAuth flow.
15
+ * `/v1/auth/login` is email and password, and a service account key is a random
16
+ * string matched against a hash — neither is an OAuth grant.
17
+ *
18
+ * So `authorization_servers` is **absent unless an operator names one**. The
19
+ * field is optional in RFC 9728, and an installation that has not put an
20
+ * identity provider in front of Nacre has no authorization server to name.
21
+ * Pointing the field at ourselves would be the same lie the 404 was, one
22
+ * redirect further along: a client would fetch our metadata, learn we are our
23
+ * own issuer, ask us for a token endpoint, and find nothing again.
24
+ *
25
+ * What the document is still worth without that field is the part clients
26
+ * actually need first: the canonical resource identifier every token must be
27
+ * audience-bound to, and where to read about the credential that does work
28
+ * today.
29
+ */
30
+ /** The path RFC 9728 fixes, and the one the `WWW-Authenticate` header names. */
31
+ export const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource';
32
+ export function protectedResourceMetadata(input) {
33
+ return {
34
+ // Without a trailing slash, because this is the audience value a token is
35
+ // bound to and `https://api.example.com` and `https://api.example.com/` are
36
+ // different strings to every audience check ever written.
37
+ resource: input.canonicalUrl.replace(/\/+$/, ''),
38
+ ...(input.authorizationServer === undefined
39
+ ? {}
40
+ : { authorization_servers: [input.authorizationServer.replace(/\/+$/, '')] }),
41
+ // Header only. A bearer token in a query string ends up in access logs,
42
+ // proxy logs and browser history, and RFC 6750 has deprecated that form for
43
+ // long enough that offering it is a liability rather than a courtesy.
44
+ bearer_methods_supported: ['header'],
45
+ resource_documentation: 'https://github.com/nacre-work/nacre/blob/main/docs/mcp.md',
46
+ // Nacre does not scope tokens. Permission is computed per call against the
47
+ // grant graph, so a token carries an organization and a role and never a
48
+ // list of what it may reach — which is why this is empty rather than
49
+ // inventing names no code reads. See docs/authz.md.
50
+ scopes_supported: [],
51
+ };
52
+ }
53
+ //# sourceMappingURL=oauth.js.map
@@ -0,0 +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 +1 @@
1
- {"version":3,"file":"observability.d.ts","sourceRoot":"","sources":["../observability.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,IAAI,CAAA;AAG9B,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,CAoFtG"}
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,4 +1,5 @@
1
1
  import { withOrg } from './db/client.js';
2
+ import { fromStateJson, reindexProgress } from './reindex.js';
2
3
  /**
3
4
  * The gauges that are queries rather than counters.
4
5
  *
@@ -18,20 +19,86 @@ export function collectDatabaseGauges(pool, metrics, role) {
18
19
  metrics.documents.reset();
19
20
  metrics.tombstonesPending.reset();
20
21
  metrics.aclPropagationLag.reset();
22
+ metrics.reindexProgress.reset();
23
+ // One series per layer that has ever been reindexed, labelled by slug
24
+ // like every other per-tenant gauge here. Layers with no reindex produce
25
+ // no row and therefore no series: inventing a zero for them would mean
26
+ // every layer in the installation permanently reading "reindex started,
27
+ // gone nowhere".
28
+ //
29
+ // Across organizations in one query rather than per tenant, because this
30
+ // reads `layers` and not `documents` — a handful of rows per tenant at
31
+ // most, and the whole point of the collapse in this collector was that a
32
+ // scrape must not cost a query per organization.
33
+ const { rows: reindexing } = await client.query(`SELECT o.slug, l.slug AS layer, l.reindex_state AS state
34
+ FROM layers l
35
+ JOIN organizations o ON o.id = l.org_id
36
+ WHERE l.reindex_state IS NOT NULL
37
+ AND l.deleted_at IS NULL AND o.deleted_at IS NULL`);
38
+ for (const row of reindexing) {
39
+ const state = fromStateJson(row.state);
40
+ if (state === undefined)
41
+ continue;
42
+ metrics.reindexProgress.set(reindexProgress(state), { org: row.slug, layer: row.layer });
43
+ }
21
44
  for (const org of orgs) {
22
- const counts = await withOrg(pool, org.id, async (c) => (await c.query(`SELECT status, count(*)::text AS n FROM documents
23
- WHERE org_id = $1 AND deleted_at IS NULL GROUP BY status`, [org.id])).rows, scope);
24
- for (const row of counts) {
25
- metrics.documents.set(Number(row.n), { org: org.slug, status: row.status });
26
- }
27
- const pending = await withOrg(pool, org.id, async (c) => (await c.query(`SELECT count(*)::text AS n FROM documents
28
- WHERE org_id = $1 AND deleted_at IS NOT NULL AND vectors_purged_at IS NULL`, [org.id])).rows[0]?.n ?? '0', scope);
29
- metrics.tombstonesPending.set(Number(pending), { org: org.slug });
30
- // The oldest document whose tags predate the current groups_version.
31
- // Zero when everything is caught up; the age of the laggard otherwise.
32
- const lag = await withOrg(pool, org.id, async (c) => (await c.query(`SELECT COALESCE(EXTRACT(EPOCH FROM (now() - min(COALESCE(acl_tagged_at, created_at)))), 0)::text AS lag
45
+ // One query per organization, not three.
46
+ //
47
+ // It was three — document counts, pending tombstones, propagation lag —
48
+ // each a separate round trip inside its own `withOrg`, so a scrape cost
49
+ // 3N+1 queries and each of those three also paid for a connection
50
+ // checkout and a `SET LOCAL app.current_org`. At five hundred tenants
51
+ // that is fifteen hundred queries every fifteen seconds, on the same
52
+ // pool the request path uses, triggered by an endpoint that anyone who
53
+ // can reach the port may call as fast as they like.
54
+ //
55
+ // All three read `documents` for one organization, so they are one
56
+ // grouped query with FILTERed aggregates. Still per organization and
57
+ // still inside `withOrg`: collapsing across tenants would mean reading
58
+ // every tenant's documents from the API process, and the row-level
59
+ // 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,
61
+ count(*) FILTER (WHERE deleted_at IS NULL)::text AS live,
62
+ count(*) FILTER (
63
+ WHERE deleted_at IS NOT NULL AND vectors_purged_at IS NULL
64
+ )::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
33
87
  FROM documents
34
- WHERE org_id = $1 AND deleted_at IS NULL AND acl_version < $2`, [org.id, Number(org.groups_version)])).rows[0]?.lag ?? '0', scope);
88
+ WHERE org_id = $1
89
+ GROUP BY status`, [org.id, Number(org.groups_version)])).rows, scope);
90
+ let tombstoned = 0;
91
+ let oldest;
92
+ for (const row of rows) {
93
+ metrics.documents.set(Number(row.live), { org: org.slug, status: row.status });
94
+ 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
+ }
100
+ }
101
+ metrics.tombstonesPending.set(tombstoned, { org: org.slug });
35
102
  // Per organization, and set even when it is zero.
36
103
  //
37
104
  // A single worst-across-tenants number was the first shape of this and
@@ -44,7 +111,8 @@ export function collectDatabaseGauges(pool, metrics, role) {
44
111
  // Zero is written rather than omitted: an absent series and a zero one
45
112
  // mean "caught up" and "not being measured", and those must not look
46
113
  // alike on the one metric that evidences I4.
47
- metrics.aclPropagationLag.set(Number(lag), { org: org.slug });
114
+ const lag = oldest === undefined ? 0 : Math.max(0, (Date.now() - oldest) / 1000);
115
+ metrics.aclPropagationLag.set(lag, { org: org.slug });
48
116
  }
49
117
  }
50
118
  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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"}
@@ -0,0 +1,37 @@
1
+ /** Raised when the process is already verifying as many passwords as it will. */
2
+ export declare class TooBusy extends Error {
3
+ constructor();
4
+ }
5
+ /** For a metric and for tests. Not a decision anything makes. */
6
+ export declare const hashingLoad: () => {
7
+ active: number;
8
+ queued: number;
9
+ limit: number;
10
+ };
11
+ /**
12
+ * `scrypt$N$r$p$salt$hash`, base64url, parameters first.
13
+ *
14
+ * Self-describing on purpose. A bare hash cannot be verified after the cost is
15
+ * raised, so raising it means invalidating every password in the database;
16
+ * carrying the parameters means old hashes keep verifying and get rewritten as
17
+ * people sign in.
18
+ */
19
+ export declare function hashPassword(password: string): Promise<string>;
20
+ /**
21
+ * Verify, in time that does not depend on whether the password was right.
22
+ *
23
+ * A malformed or absent hash answers false rather than throwing: the caller
24
+ * turns every failure into the same refusal, and an exception on one of them
25
+ * would be a way to tell them apart from the outside.
26
+ */
27
+ export declare function verifyPassword(password: string, encoded: string): Promise<boolean>;
28
+ /**
29
+ * Whether a stored hash was made with parameters weaker than the current ones.
30
+ *
31
+ * The caller rehashes on a successful sign-in, which is the only moment the
32
+ * plaintext exists. Without this, raising the cost protects nobody who already
33
+ * has an account.
34
+ */
35
+ export declare function needsRehash(encoded: string): boolean;
36
+ export declare function spendVerificationTime(password: string): Promise<false>;
37
+ //# sourceMappingURL=passwords.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passwords.d.ts","sourceRoot":"","sources":["../passwords.ts"],"names":[],"mappings":"AA8FA,iFAAiF;AACjF,qBAAa,OAAQ,SAAQ,KAAK;;CAKjC;AAyCD,iEAAiE;AACjE,eAAO,MAAM,WAAW,QAAO;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAI5E,CAAA;AAEF;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAWpE;AAmCD;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAexF;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAIpD;AAgBD,wBAAsB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAI5E"}