@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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/dist/authz/filter.d.ts +54 -3
- package/dist/authz/filter.d.ts.map +1 -1
- package/dist/authz/filter.js +45 -9
- package/dist/authz/filter.js.map +1 -1
- package/dist/authz/index.d.ts +2 -2
- package/dist/authz/index.d.ts.map +1 -1
- package/dist/authz/index.js +1 -1
- package/dist/authz/index.js.map +1 -1
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +137 -3
- package/dist/config.js.map +1 -1
- package/dist/db/client.d.ts +39 -0
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +82 -0
- package/dist/db/client.js.map +1 -1
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +49 -0
- package/dist/db/migrate.js.map +1 -1
- package/dist/index.d.ts +15 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/lifecycle.d.ts +57 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +97 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/metadata.d.ts +50 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +127 -0
- package/dist/metadata.js.map +1 -0
- package/dist/metrics.d.ts +18 -1
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +77 -2
- package/dist/metrics.js.map +1 -1
- package/dist/migrations/0008_authenticating_lookup.sql +124 -0
- package/dist/migrations/0009_refresh_tokens.sql +73 -0
- package/dist/migrations/0010_definer_search_path.sql +22 -0
- package/dist/migrations/0011_sweep_lease.sql +36 -0
- package/dist/migrations/0012_prune.sql +118 -0
- package/dist/migrations/0013_reindex.sql +65 -0
- package/dist/oauth.d.ts +45 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +53 -0
- package/dist/oauth.js.map +1 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +81 -13
- package/dist/observability.js.map +1 -1
- package/dist/passwords.d.ts +37 -0
- package/dist/passwords.d.ts.map +1 -0
- package/dist/passwords.js +234 -0
- package/dist/passwords.js.map +1 -0
- package/dist/redis.d.ts +46 -0
- package/dist/redis.d.ts.map +1 -0
- package/dist/redis.js +234 -0
- package/dist/redis.js.map +1 -0
- package/dist/reindex.d.ts +87 -0
- package/dist/reindex.d.ts.map +1 -0
- package/dist/reindex.js +97 -0
- package/dist/reindex.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/vector/query.d.ts +49 -4
- package/dist/vector/query.d.ts.map +1 -1
- package/dist/vector/query.js +43 -10
- package/dist/vector/query.js.map +1 -1
- package/dist/vector/search.d.ts +109 -4
- package/dist/vector/search.d.ts.map +1 -1
- package/dist/vector/search.js +166 -7
- package/dist/vector/search.js.map +1 -1
- 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
|
+
);
|
package/dist/oauth.d.ts
ADDED
|
@@ -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;
|
|
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"}
|
package/dist/observability.js
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
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":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;
|
|
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"}
|