@nacre.work/core 0.2.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.
- package/dist/authz/index.d.ts +0 -1
- package/dist/authz/index.d.ts.map +1 -1
- package/dist/authz/index.js +0 -1
- package/dist/authz/index.js.map +1 -1
- package/dist/config.d.ts +110 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +347 -34
- package/dist/config.js.map +1 -1
- package/dist/db/migrate.d.ts +45 -0
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +61 -0
- package/dist/db/migrate.js.map +1 -1
- package/dist/extensions.d.ts +298 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +246 -0
- package/dist/extensions.js.map +1 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/metrics.d.ts +10 -7
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +25 -7
- package/dist/metrics.js.map +1 -1
- package/dist/migrations/0015_reference_queries.sql +97 -0
- package/dist/migrations/0016_drop_acl_tag_cache.sql +62 -0
- package/dist/migrations/0017_drop_workspace_admin_role.sql +30 -0
- package/dist/migrations/0018_group_members_nulls_not_distinct.sql +46 -0
- package/dist/migrations/0019_embedding_provider_write_guard.sql +58 -0
- package/dist/multipart.d.ts +55 -0
- package/dist/multipart.d.ts.map +1 -0
- package/dist/multipart.js +158 -0
- package/dist/multipart.js.map +1 -0
- package/dist/oauth.d.ts +10 -0
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +10 -0
- package/dist/oauth.js.map +1 -1
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +24 -46
- package/dist/observability.js.map +1 -1
- package/dist/reindex.d.ts +44 -0
- package/dist/reindex.d.ts.map +1 -1
- package/dist/reindex.js +49 -0
- package/dist/reindex.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/vector/query.d.ts +7 -9
- package/dist/vector/query.d.ts.map +1 -1
- package/dist/vector/query.js +7 -6
- package/dist/vector/query.js.map +1 -1
- package/dist/vector/search.d.ts +20 -1
- package/dist/vector/search.d.ts.map +1 -1
- package/dist/vector/search.js +31 -1
- package/dist/vector/search.js.map +1 -1
- package/package.json +1 -1
- package/dist/authz/tags.d.ts +0 -40
- package/dist/authz/tags.d.ts.map +0 -1
- package/dist/authz/tags.js +0 -48
- package/dist/authz/tags.js.map +0 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
-- 0016 — remove the ACL tag cache.
|
|
2
|
+
--
|
|
3
|
+
-- `docs/authz.md` 3.6 specified `acl_tags` in the vector payload as a second
|
|
4
|
+
-- filter alongside the layer bound: "both the layer filter and the tag filter
|
|
5
|
+
-- apply". The filter was never built. `buildFilter` is the only filter builder
|
|
6
|
+
-- in the codebase and it emits `org_id`, `deleted`, the plan's layers and
|
|
7
|
+
-- documents as a `should`, a `must_not` on denied documents, and the caller's
|
|
8
|
+
-- metadata narrowing — and no tag clause. So the whole subsystem kept a payload
|
|
9
|
+
-- field fresh that no query read.
|
|
10
|
+
--
|
|
11
|
+
-- Two things settled it, and only the second is decisive.
|
|
12
|
+
--
|
|
13
|
+
-- **It saves nothing.** The tags were justified as a speed-up over "joining
|
|
14
|
+
-- back to Postgres". Nothing joins back: the resolver computes the caller's
|
|
15
|
+
-- whole permitted set from `grants` on every request, so the expensive part has
|
|
16
|
+
-- already happened by the time a filter is built. A tag clause would be a
|
|
17
|
+
-- second constraint derived from the same table, one request later.
|
|
18
|
+
--
|
|
19
|
+
-- **It cannot express what the product now sells.** `tagsForLayer` read
|
|
20
|
+
-- `effect = 'allow'` grants on the layer and its workspace — document-scoped
|
|
21
|
+
-- grants were invisible to it, and so were denies. Applied as the `must` the
|
|
22
|
+
-- specification asks for, a caller reaching a document through a
|
|
23
|
+
-- document-scoped grant would have been filtered *out*: their principal is not
|
|
24
|
+
-- in that layer's tag set. Document-scoped grants are issuable now, through
|
|
25
|
+
-- `@nacre.work/enterprise-acl-advanced`, so the specified design and a shipped
|
|
26
|
+
-- feature could not both be true.
|
|
27
|
+
--
|
|
28
|
+
-- Making tags per-document would have fixed that and left the other cost: the
|
|
29
|
+
-- tag filter is stale by construction, so a *newly granted* document would stay
|
|
30
|
+
-- invisible until the sweep caught up. Revocation is already immediate — the
|
|
31
|
+
-- plan is computed per request, and the effective-principals cache is keyed on
|
|
32
|
+
-- `organizations.groups_version`, which bumps on every write to `grants` — so
|
|
33
|
+
-- the trade was a delay on grants in exchange for nothing on revocations.
|
|
34
|
+
--
|
|
35
|
+
-- What goes with it: the retag sweep, its lease, the propagation gauge and its
|
|
36
|
+
-- alert, `NACRE_ACL_TAG_HASH_BYTES` and `NACRE_ACL_PROPAGATION_SLA`. Invariant
|
|
37
|
+
-- I4 is unchanged and is now argued structurally rather than measured
|
|
38
|
+
-- temporally: there is no cache between a grant change and the next request.
|
|
39
|
+
--
|
|
40
|
+
-- `organizations.groups_version` stays. It is the effective-principals cache
|
|
41
|
+
-- key, which is read on every request, and its triggers are what make that
|
|
42
|
+
-- cache safe.
|
|
43
|
+
|
|
44
|
+
ALTER TABLE documents DROP COLUMN IF EXISTS acl_version;
|
|
45
|
+
ALTER TABLE documents DROP COLUMN IF EXISTS acl_tagged_at;
|
|
46
|
+
|
|
47
|
+
-- Dropped with its column. Named explicitly because a partial index over a
|
|
48
|
+
-- dropped column disappears silently, and a reader of 0004 should be able to
|
|
49
|
+
-- find where it went.
|
|
50
|
+
DROP INDEX IF EXISTS documents_acl_version_idx;
|
|
51
|
+
|
|
52
|
+
-- `sweep_claimed_at` **stays**, and 0011's name for it is now misleading rather
|
|
53
|
+
-- than wrong. It was added for the retag sweep and the collector took it as
|
|
54
|
+
-- well: `claimPurgeable` sets it to lease a tombstone so two workers do not
|
|
55
|
+
-- purge the same points, and `markPurged` clears it. Dropping it here removed a
|
|
56
|
+
-- column the collector reads — caught by the garbage-collection tests against a
|
|
57
|
+
-- real database, which is the only place it could have been caught.
|
|
58
|
+
COMMENT ON COLUMN documents.sweep_claimed_at IS
|
|
59
|
+
'Lease held by the garbage collector while it purges a tombstone''s vectors. Cleared by markPurged, and reclaimed after NACRE_INDEX_LEASE if the worker holding it dies. Shared with the retag sweep until 0016 removed that sweep.';
|
|
60
|
+
|
|
61
|
+
COMMENT ON COLUMN organizations.groups_version IS
|
|
62
|
+
'Permission epoch. Bumped by triggers on groups, group_members and grants. Read as the effective-principals cache key — which is what makes caching a permission input safe, since a change composes a different key rather than expiring an old one.';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- 0017 — `users.role` loses `workspace_admin`.
|
|
2
|
+
--
|
|
3
|
+
-- 0001 wrote `CHECK (role IN ('platform_admin','org_admin','workspace_admin','member'))`
|
|
4
|
+
-- and `OrgRole` in the code has always been three values. A row carrying the
|
|
5
|
+
-- fourth read into a type that does not admit it and then behaved as `member`,
|
|
6
|
+
-- because it matches neither role branch in `resolve` and falls through to
|
|
7
|
+
-- grant-based evaluation. Safe — it denies rather than widens — and still a
|
|
8
|
+
-- role the schema offered, nothing implemented, and nothing refused: an
|
|
9
|
+
-- operator who set it got a silently demoted user and no error.
|
|
10
|
+
--
|
|
11
|
+
-- Removed rather than implemented, because it is a category error and not a
|
|
12
|
+
-- missing feature. `users.role` is organization-wide; "administers a
|
|
13
|
+
-- workspace" is scoped to one. The permission model already expresses it, and
|
|
14
|
+
-- expresses it better: `admin` on a `workspace` scope, which a person can hold
|
|
15
|
+
-- on several workspaces at once and which a single column cannot say. Adding
|
|
16
|
+
-- the role would have created a second way to spell one thing, with the
|
|
17
|
+
-- resolver having to decide which wins.
|
|
18
|
+
--
|
|
19
|
+
-- Existing rows are moved to `member`, which is what they already behaved as.
|
|
20
|
+
-- The UPDATE runs before the constraint is replaced, or the constraint would
|
|
21
|
+
-- refuse the rows it exists to remove.
|
|
22
|
+
|
|
23
|
+
UPDATE users SET role = 'member' WHERE role = 'workspace_admin';
|
|
24
|
+
|
|
25
|
+
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
|
26
|
+
ALTER TABLE users ADD CONSTRAINT users_role_check
|
|
27
|
+
CHECK (role IN ('platform_admin', 'org_admin', 'member'));
|
|
28
|
+
|
|
29
|
+
COMMENT ON COLUMN users.role IS
|
|
30
|
+
'Organization-wide role. Anything scoped to a workspace, a layer or a document is a grant, not a role — see docs/authz.md rules 2 and 3.';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- 0018 — `group_members` can actually hold a member only once.
|
|
2
|
+
--
|
|
3
|
+
-- 0001 wrote `UNIQUE (group_id, member_user, member_group)` with a CHECK that
|
|
4
|
+
-- exactly one of the two member columns is non-null. **In Postgres a unique
|
|
5
|
+
-- index treats NULLs as distinct by default**, so two rows with the same
|
|
6
|
+
-- `group_id` and `member_user` and a NULL `member_group` do not conflict — and
|
|
7
|
+
-- every row this table holds has one of the two columns NULL, by construction.
|
|
8
|
+
--
|
|
9
|
+
-- The constraint therefore deduplicated nothing at all, in either direction.
|
|
10
|
+
-- `ON CONFLICT DO NOTHING` against it is a no-op, which is exactly how it was
|
|
11
|
+
-- found: a SCIM group sync adding the same member twice produced two rows and
|
|
12
|
+
-- an idempotent re-sync doubled a group's membership every time it ran.
|
|
13
|
+
--
|
|
14
|
+
-- Not a permission bug. `effectivePrincipals` walks the graph into a `Set`, so
|
|
15
|
+
-- a duplicated edge resolves to the same principal set and grants nothing
|
|
16
|
+
-- extra. What it is: a table that grows without bound under a directory sync,
|
|
17
|
+
-- a membership count that is wrong wherever one is shown, and — worse than
|
|
18
|
+
-- either — a uniqueness guarantee the schema appears to make and does not, so
|
|
19
|
+
-- the next writer reaches for `ON CONFLICT` and gets silence.
|
|
20
|
+
--
|
|
21
|
+
-- `NULLS NOT DISTINCT` needs Postgres 15; the Compose image is 17 and
|
|
22
|
+
-- docs/config.md has always named 17. Dropping to a pair of partial unique
|
|
23
|
+
-- indexes would work on older versions and is not worth carrying a version
|
|
24
|
+
-- floor nothing else needs.
|
|
25
|
+
--
|
|
26
|
+
-- Duplicates are collapsed before the constraint is replaced, or the constraint
|
|
27
|
+
-- refuses the rows it exists to prevent. `ctid` is the only thing that
|
|
28
|
+
-- distinguishes them, which is what "duplicate" means here.
|
|
29
|
+
|
|
30
|
+
DELETE FROM group_members a
|
|
31
|
+
USING group_members b
|
|
32
|
+
WHERE a.ctid > b.ctid
|
|
33
|
+
AND a.group_id = b.group_id
|
|
34
|
+
AND a.org_id = b.org_id
|
|
35
|
+
AND a.member_user IS NOT DISTINCT FROM b.member_user
|
|
36
|
+
AND a.member_group IS NOT DISTINCT FROM b.member_group;
|
|
37
|
+
|
|
38
|
+
ALTER TABLE group_members
|
|
39
|
+
DROP CONSTRAINT IF EXISTS group_members_group_id_member_user_member_group_key;
|
|
40
|
+
|
|
41
|
+
ALTER TABLE group_members
|
|
42
|
+
ADD CONSTRAINT group_members_member_unique
|
|
43
|
+
UNIQUE NULLS NOT DISTINCT (group_id, member_user, member_group);
|
|
44
|
+
|
|
45
|
+
COMMENT ON CONSTRAINT group_members_member_unique ON group_members IS
|
|
46
|
+
'NULLS NOT DISTINCT, deliberately: exactly one of member_user and member_group is non-null on every row, so the default NULLS DISTINCT made this constraint match nothing.';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
-- 0019 — a tenant may read the installation-wide embedding provider, never write it.
|
|
2
|
+
--
|
|
3
|
+
-- `embedding_providers` is the one table where a NULL `org_id` is legitimate: it
|
|
4
|
+
-- is the installation-wide default model, and 0002's `org_isolation` lets every
|
|
5
|
+
-- organization *read* it, on purpose. 0002 also carried this, two lines down:
|
|
6
|
+
--
|
|
7
|
+
-- -- Writing a global default is not something a tenant may do.
|
|
8
|
+
-- CREATE POLICY org_writes_own ON embedding_providers
|
|
9
|
+
-- FOR INSERT WITH CHECK (org_id = current_setting('app.current_org')::uuid);
|
|
10
|
+
--
|
|
11
|
+
-- and that policy does nothing. A PERMISSIVE policy is OR'd with the other
|
|
12
|
+
-- PERMISSIVE policy for the same command, so on INSERT the effective check is
|
|
13
|
+
-- `org_writes_own` OR `org_isolation` — and `org_isolation`'s check admits
|
|
14
|
+
-- `org_id IS NULL`. `org_writes_own` is a strict subset of what was already
|
|
15
|
+
-- allowed, so adding it changed nothing. It was verified inert against a real
|
|
16
|
+
-- database: a connection scoped to org A, as `nacre_app`, could INSERT, UPDATE
|
|
17
|
+
-- and DELETE the NULL-`org_id` row every other tenant reads as its default.
|
|
18
|
+
--
|
|
19
|
+
-- The consequence is the shape the whole schema's FORCE RLS exists to prevent,
|
|
20
|
+
-- one table over from where it was checked: repoint that endpoint and every
|
|
21
|
+
-- other tenant's documents are embedded by an attacker's service; delete it and
|
|
22
|
+
-- every tenant relying on the default stops resolving a model. No core endpoint
|
|
23
|
+
-- writes this table, so this is the second line of defence rather than a
|
|
24
|
+
-- reachable exploit today — but the second line is exactly what a NULL-`org_id`
|
|
25
|
+
-- row needs, because the first line (a handler setting `org_id` from the token)
|
|
26
|
+
-- cannot cover a row whose `org_id` is meant to be NULL.
|
|
27
|
+
--
|
|
28
|
+
-- The fix is RESTRICTIVE policies on the three writing commands. A RESTRICTIVE
|
|
29
|
+
-- policy is AND'd with the permissive result, so it can only ever remove access
|
|
30
|
+
-- — it cannot widen a read. SELECT carries none, so the global row stays
|
|
31
|
+
-- readable by every tenant exactly as before.
|
|
32
|
+
|
|
33
|
+
DROP POLICY IF EXISTS org_writes_own ON embedding_providers;
|
|
34
|
+
|
|
35
|
+
-- Each names the writing role's own organization and nothing else. `org_id`
|
|
36
|
+
-- NULL fails the equality (NULL = uuid is NULL, not true), which is what keeps
|
|
37
|
+
-- the global row out of a tenant's reach on every one of the three commands.
|
|
38
|
+
CREATE POLICY tenant_writes_own_insert ON embedding_providers
|
|
39
|
+
AS RESTRICTIVE FOR INSERT
|
|
40
|
+
WITH CHECK (org_id = current_setting('app.current_org')::uuid);
|
|
41
|
+
|
|
42
|
+
CREATE POLICY tenant_writes_own_update ON embedding_providers
|
|
43
|
+
AS RESTRICTIVE FOR UPDATE
|
|
44
|
+
USING (org_id = current_setting('app.current_org')::uuid)
|
|
45
|
+
WITH CHECK (org_id = current_setting('app.current_org')::uuid);
|
|
46
|
+
|
|
47
|
+
CREATE POLICY tenant_writes_own_delete ON embedding_providers
|
|
48
|
+
AS RESTRICTIVE FOR DELETE
|
|
49
|
+
USING (org_id = current_setting('app.current_org')::uuid);
|
|
50
|
+
|
|
51
|
+
-- The installation-wide default is still written once, by `init`, and it is a
|
|
52
|
+
-- NULL-`org_id` row — so it now goes in under `nacre_worker`, the same
|
|
53
|
+
-- BYPASSRLS role the worker's cross-tenant queue uses. `init` is the only
|
|
54
|
+
-- writer of that row (a per-organization provider carries the org's own id and
|
|
55
|
+
-- passes the policies above unchanged), which is why the exception is one
|
|
56
|
+
-- statement in one bootstrap command rather than a hole in the policy.
|
|
57
|
+
COMMENT ON POLICY tenant_writes_own_insert ON embedding_providers IS
|
|
58
|
+
'A tenant writes only its own providers. The NULL-org_id installation default is written by init under nacre_worker; see 0019.';
|
|
@@ -0,0 +1,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;
|
package/dist/oauth.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/observability.js
CHANGED
|
@@ -15,10 +15,10 @@ export function collectDatabaseGauges(pool, metrics, role) {
|
|
|
15
15
|
// Across organizations, so it runs outside withOrg under the role that
|
|
16
16
|
// may see them all. Every query below names org_id in its output rather
|
|
17
17
|
// than filtering to one.
|
|
18
|
-
const { rows: orgs } = await client.query('SELECT id, slug
|
|
18
|
+
const { rows: orgs } = await client.query('SELECT id, slug FROM organizations WHERE deleted_at IS NULL');
|
|
19
19
|
metrics.documents.reset();
|
|
20
20
|
metrics.tombstonesPending.reset();
|
|
21
|
-
metrics.
|
|
21
|
+
metrics.processingAge.reset();
|
|
22
22
|
metrics.reindexProgress.reset();
|
|
23
23
|
metrics.collectionsRetired.reset();
|
|
24
24
|
// One series per layer that has ever been reindexed, labelled by slug
|
|
@@ -76,62 +76,40 @@ export function collectDatabaseGauges(pool, metrics, role) {
|
|
|
76
76
|
// still inside `withOrg`: collapsing across tenants would mean reading
|
|
77
77
|
// every tenant's documents from the API process, and the row-level
|
|
78
78
|
// policies are not something to route around for a gauge.
|
|
79
|
-
const rows = await withOrg(pool, org.id, async (c) => (await c.query(
|
|
79
|
+
const rows = await withOrg(pool, org.id, async (c) => (await c.query(
|
|
80
|
+
// claim_age rides this query rather than adding one: claimed_at
|
|
81
|
+
// is on the same documents rows, non-null only while a document
|
|
82
|
+
// is being indexed, and cleared on success or failure. The max
|
|
83
|
+
// per status is the oldest in-flight document; the max across
|
|
84
|
+
// statuses is taken below, so a worker wedged in either
|
|
85
|
+
// `parsing` or `indexing` shows up.
|
|
86
|
+
`SELECT status,
|
|
80
87
|
count(*) FILTER (WHERE deleted_at IS NULL)::text AS live,
|
|
81
88
|
count(*) FILTER (
|
|
82
89
|
WHERE deleted_at IS NOT NULL AND vectors_purged_at IS NULL
|
|
83
90
|
)::text AS tombstoned,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
-- number is the age of the oldest document still
|
|
88
|
-
-- carrying tags built from a superseded grant set, which
|
|
89
|
-
-- only a document with points in the index can do.
|
|
90
|
-
-- Without the clause a single failed or pending
|
|
91
|
-
-- document pinned the gauge at its own age forever,
|
|
92
|
-
-- because the retag loop claims neither and nothing else
|
|
93
|
-
-- can clear it — the one metric with an alert on it
|
|
94
|
-
-- became a stuck alert, which is how it gets muted.
|
|
95
|
-
--
|
|
96
|
-
-- Deliberately not status = 'indexed'. A document that
|
|
97
|
-
-- indexed once and failed on a later pass still has the
|
|
98
|
-
-- earlier pass's points and still carries their tags,
|
|
99
|
-
-- and is exactly the case this is meant to catch.
|
|
100
|
-
-- claimStale uses the same predicate; the two must
|
|
101
|
-
-- agree, or the gauge counts what the loop cannot claim.
|
|
102
|
-
min(COALESCE(acl_tagged_at, created_at)) FILTER (
|
|
103
|
-
WHERE deleted_at IS NULL AND chunk_count > 0
|
|
104
|
-
AND acl_version < $2
|
|
105
|
-
)::text AS oldest_stale
|
|
91
|
+
extract(
|
|
92
|
+
epoch FROM max(now() - claimed_at) FILTER (WHERE claimed_at IS NOT NULL)
|
|
93
|
+
) AS claim_age
|
|
106
94
|
FROM documents
|
|
107
95
|
WHERE org_id = $1
|
|
108
|
-
GROUP BY status`, [org.id
|
|
96
|
+
GROUP BY status`, [org.id])).rows, scope);
|
|
109
97
|
let tombstoned = 0;
|
|
110
|
-
let
|
|
98
|
+
let claimAge = 0;
|
|
111
99
|
for (const row of rows) {
|
|
112
100
|
metrics.documents.set(Number(row.live), { org: org.slug, status: row.status });
|
|
113
101
|
tombstoned += Number(row.tombstoned);
|
|
114
|
-
if (row.
|
|
115
|
-
|
|
116
|
-
if (!Number.isNaN(at) && (oldest === undefined || at < oldest))
|
|
117
|
-
oldest = at;
|
|
118
|
-
}
|
|
102
|
+
if (row.claim_age !== null)
|
|
103
|
+
claimAge = Math.max(claimAge, Number(row.claim_age));
|
|
119
104
|
}
|
|
120
105
|
metrics.tombstonesPending.set(tombstoned, { org: org.slug });
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// aggregate cost nothing and bought nothing.
|
|
129
|
-
//
|
|
130
|
-
// Zero is written rather than omitted: an absent series and a zero one
|
|
131
|
-
// mean "caught up" and "not being measured", and those must not look
|
|
132
|
-
// alike on the one metric that evidences I4.
|
|
133
|
-
const lag = oldest === undefined ? 0 : Math.max(0, (Date.now() - oldest) / 1000);
|
|
134
|
-
metrics.aclPropagationLag.set(lag, { org: org.slug });
|
|
106
|
+
// Only when something is in flight. A zero for every idle tenant would
|
|
107
|
+
// be true and useless; the alertable shape is a series that exists and
|
|
108
|
+
// climbs, so no claimed document means no series, the same way the
|
|
109
|
+
// reindex and retired-collection gauges stay silent about tenants with
|
|
110
|
+
// nothing to report.
|
|
111
|
+
if (claimAge > 0)
|
|
112
|
+
metrics.processingAge.set(claimAge, { org: org.slug });
|
|
135
113
|
}
|
|
136
114
|
}
|
|
137
115
|
finally {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"observability.js","sourceRoot":"","sources":["../observability.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AACxC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAG7D;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAAU,EAAE,OAAgB,EAAE,IAAa;IAC/E,MAAM,KAAK,GAAG,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAA;IAEhD,OAAO,KAAK,IAAI,EAAE;QAChB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAA;QACnC,IAAI,CAAC;YACH,uEAAuE;YACvE,wEAAwE;YACxE,yBAAyB;YACzB,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,KAAK,CACvC,
|
|
1
|
+
{"version":3,"file":"observability.js","sourceRoot":"","sources":["../observability.ts"],"names":[],"mappings":"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"}
|