@nacre.work/api 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/adapters.d.ts +326 -15
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +992 -52
- package/dist/adapters.js.map +1 -1
- package/dist/audit-export.d.ts +45 -0
- package/dist/audit-export.d.ts.map +1 -0
- package/dist/audit-export.js +155 -0
- package/dist/audit-export.js.map +1 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +17 -7
- package/dist/auth.js.map +1 -1
- package/dist/idempotency.d.ts +62 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +147 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +9 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +50 -5
- package/dist/init.js.map +1 -1
- package/dist/limits.d.ts +80 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +63 -0
- package/dist/limits.js.map +1 -0
- package/dist/login.d.ts +94 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +283 -0
- package/dist/login.js.map +1 -0
- package/dist/main.js +136 -53
- package/dist/main.js.map +1 -1
- package/dist/pagination.d.ts +72 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +110 -0
- package/dist/pagination.js.map +1 -0
- package/dist/rerank.d.ts +99 -0
- package/dist/rerank.d.ts.map +1 -0
- package/dist/rerank.js +138 -0
- package/dist/rerank.js.map +1 -0
- package/dist/server.d.ts +345 -6
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1035 -24
- package/dist/server.js.map +1 -1
- package/dist/service-keys.d.ts +13 -5
- package/dist/service-keys.d.ts.map +1 -1
- package/dist/service-keys.js +54 -31
- package/dist/service-keys.js.map +1 -1
- package/dist/source.d.ts +53 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +109 -0
- package/dist/source.js.map +1 -0
- package/package.json +28 -10
- package/dist/__tests__/admin-surface.test.d.ts +0 -2
- package/dist/__tests__/admin-surface.test.d.ts.map +0 -1
- package/dist/__tests__/admin-surface.test.js +0 -431
- package/dist/__tests__/admin-surface.test.js.map +0 -1
- package/dist/__tests__/init.test.d.ts +0 -2
- package/dist/__tests__/init.test.d.ts.map +0 -1
- package/dist/__tests__/init.test.js +0 -125
- package/dist/__tests__/init.test.js.map +0 -1
- package/dist/__tests__/service-keys.test.d.ts +0 -2
- package/dist/__tests__/service-keys.test.d.ts.map +0 -1
- package/dist/__tests__/service-keys.test.js +0 -204
- package/dist/__tests__/service-keys.test.js.map +0 -1
package/dist/login.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { hashPassword, needsRehash, spendVerificationTime, verifyPassword, whileAuthenticating, withOrg, } from '@nacre.work/core';
|
|
3
|
+
import { SignJWT } from 'jose';
|
|
4
|
+
/**
|
|
5
|
+
* Email and password sign-in.
|
|
6
|
+
*
|
|
7
|
+
* The last thing in `docs/api.md` that was described and not built. Until now
|
|
8
|
+
* the only credentials were a token printed by `init` — valid for an hour,
|
|
9
|
+
* signed with a symmetric secret, and printed into somebody's shell history —
|
|
10
|
+
* and service account keys, which are for programs.
|
|
11
|
+
*
|
|
12
|
+
* SSO is a commercial module. This is the one every self-hoster gets, which is
|
|
13
|
+
* why it is here rather than there.
|
|
14
|
+
*
|
|
15
|
+
* ## Invariant I1 at the one moment there is no token
|
|
16
|
+
*
|
|
17
|
+
* "The organization comes from the token, never from a body" governs
|
|
18
|
+
* authenticated requests. Login is the request that has no token yet, and the
|
|
19
|
+
* rule still shapes it: the body may name an organization, but only as part of
|
|
20
|
+
* the lookup key. **What ends up in the token is the `org_id` on the row that
|
|
21
|
+
* authenticated**, never the string the caller sent. A caller who names one
|
|
22
|
+
* organization and holds a password in another gets a refusal, not a token for
|
|
23
|
+
* either.
|
|
24
|
+
*
|
|
25
|
+
* The slug is optional because a single-organization installation — which is
|
|
26
|
+
* what the open core is — should not make people type it. Omitted, the email
|
|
27
|
+
* has to match exactly one user in the whole installation; zero and several
|
|
28
|
+
* are the same refusal, so this cannot be used to ask how many organizations
|
|
29
|
+
* an address appears in.
|
|
30
|
+
*
|
|
31
|
+
* ## One refusal, always
|
|
32
|
+
*
|
|
33
|
+
* Unknown address, wrong password, wrong organization, disabled account, an
|
|
34
|
+
* account with no password set: all `401`, same wording, same time spent. The
|
|
35
|
+
* timing is why `spendVerificationTime` exists — an early return on "no such
|
|
36
|
+
* user" makes the response time an oracle for which addresses have accounts,
|
|
37
|
+
* and that would undo the care taken everywhere else about not confirming what
|
|
38
|
+
* exists.
|
|
39
|
+
*/
|
|
40
|
+
const ACCESS_MIN_SECONDS = 60;
|
|
41
|
+
const digest = (token) => createHash('sha256').update(token).digest('hex');
|
|
42
|
+
/** 32 bytes from the CSPRNG. Opaque — it is looked up, never parsed. */
|
|
43
|
+
const mint = () => randomBytes(32).toString('base64url');
|
|
44
|
+
export class Login {
|
|
45
|
+
deps;
|
|
46
|
+
constructor(deps) {
|
|
47
|
+
this.deps = deps;
|
|
48
|
+
}
|
|
49
|
+
get scope() {
|
|
50
|
+
return this.deps.role === undefined ? {} : { role: this.deps.role };
|
|
51
|
+
}
|
|
52
|
+
get clock() {
|
|
53
|
+
return this.deps.now?.() ?? new Date();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Sign in, or refuse.
|
|
57
|
+
*
|
|
58
|
+
* `undefined` for every reason it can fail. The caller turns it into one
|
|
59
|
+
* `401` with one message.
|
|
60
|
+
*/
|
|
61
|
+
async login(request) {
|
|
62
|
+
const email = request.email.trim().toLowerCase();
|
|
63
|
+
if (email === '' || request.password === '') {
|
|
64
|
+
// Still spend the time. A short-circuit on an empty field is a free probe
|
|
65
|
+
// for how long the real path takes.
|
|
66
|
+
await spendVerificationTime(request.password);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const candidate = await this.find(email, request.organization);
|
|
70
|
+
if (candidate === undefined || candidate.password_hash === null || candidate.disabled_at !== null) {
|
|
71
|
+
await spendVerificationTime(request.password);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
if (!(await verifyPassword(request.password, candidate.password_hash)))
|
|
75
|
+
return undefined;
|
|
76
|
+
// The only moment the plaintext exists, so the only moment a stored hash
|
|
77
|
+
// made with weaker parameters can be brought up to date.
|
|
78
|
+
if (needsRehash(candidate.password_hash)) {
|
|
79
|
+
const rehashed = await hashPassword(request.password);
|
|
80
|
+
await withOrg(this.deps.pool, candidate.org_id, (client) => client.query('UPDATE users SET password_hash = $1 WHERE id = $2', [rehashed, candidate.id]), this.scope).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
return this.issue(candidate, undefined);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Find the one user this login is about, without saying how many nearly were.
|
|
86
|
+
*
|
|
87
|
+
* `organizations` carries no row-level security — an organization is not
|
|
88
|
+
* inside one — so the slug resolves on an ordinary connection, and the user
|
|
89
|
+
* is then read through `withOrg` like everything else. That is why the login
|
|
90
|
+
* path needs no cross-tenant read and no escape hatch: it is two scoped
|
|
91
|
+
* queries rather than one unscoped one.
|
|
92
|
+
*/
|
|
93
|
+
async find(email, organization) {
|
|
94
|
+
if (organization !== undefined && organization.trim() !== '') {
|
|
95
|
+
const orgId = await this.orgIdForSlug(organization.trim().toLowerCase());
|
|
96
|
+
if (orgId === undefined)
|
|
97
|
+
return undefined;
|
|
98
|
+
return this.userIn(orgId, email);
|
|
99
|
+
}
|
|
100
|
+
// No slug. Which is the ordinary case for the single-organization
|
|
101
|
+
// installation the open core is, and has to stay silent about how many
|
|
102
|
+
// organizations exist when it is not.
|
|
103
|
+
const orgIds = await this.allOrgIds();
|
|
104
|
+
const found = [];
|
|
105
|
+
for (const orgId of orgIds) {
|
|
106
|
+
const row = await this.userIn(orgId, email);
|
|
107
|
+
if (row !== undefined)
|
|
108
|
+
found.push(row);
|
|
109
|
+
// Two is already ambiguous; there is nothing to learn from a third, and
|
|
110
|
+
// stopping keeps the cost of a login independent of how many tenants a
|
|
111
|
+
// deployment has.
|
|
112
|
+
if (found.length > 1)
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
return found[0];
|
|
116
|
+
}
|
|
117
|
+
async orgIdForSlug(slug) {
|
|
118
|
+
const client = await this.deps.pool.connect();
|
|
119
|
+
try {
|
|
120
|
+
const { rows } = await client.query('SELECT id FROM organizations WHERE slug = $1 AND deleted_at IS NULL', [slug]);
|
|
121
|
+
return rows[0]?.id;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
client.release();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async allOrgIds() {
|
|
128
|
+
const client = await this.deps.pool.connect();
|
|
129
|
+
try {
|
|
130
|
+
const { rows } = await client.query('SELECT id FROM organizations WHERE deleted_at IS NULL');
|
|
131
|
+
return rows.map((r) => r.id);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
client.release();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async userIn(orgId, email) {
|
|
138
|
+
return withOrg(this.deps.pool, orgId, async (client) => {
|
|
139
|
+
const { rows } = await client.query(`SELECT id, org_id, role, password_hash, disabled_at
|
|
140
|
+
FROM users WHERE org_id = $1 AND email = $2`, [orgId, email]);
|
|
141
|
+
return rows[0];
|
|
142
|
+
}, this.scope);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Exchange a refresh token for a new pair, or refuse.
|
|
146
|
+
*
|
|
147
|
+
* Rotation on every use. A token presented after it has been used is not
|
|
148
|
+
* tolerated as a client retry: the legitimate holder already exchanged it, so
|
|
149
|
+
* a second presentation means two parties hold the same token and one of them
|
|
150
|
+
* took it. Which one is unknowable from here, so the whole family is revoked
|
|
151
|
+
* and both sign in again.
|
|
152
|
+
*/
|
|
153
|
+
async refresh(token) {
|
|
154
|
+
const row = await whileAuthenticating(this.deps.pool, async (client) => {
|
|
155
|
+
const { rows } = await client.query(`SELECT id, org_id, user_id, family_id, expires_at, used_at, revoked_at, token_hash
|
|
156
|
+
FROM refresh_tokens WHERE token_hash = $1`, [digest(token)]);
|
|
157
|
+
return rows[0];
|
|
158
|
+
}, this.scope);
|
|
159
|
+
if (row === undefined)
|
|
160
|
+
return undefined;
|
|
161
|
+
// Constant-time on the hash as well. The lookup already matched it, so this
|
|
162
|
+
// guards only against a future where the query becomes a prefix match — but
|
|
163
|
+
// that is exactly the change that would otherwise turn this into an oracle.
|
|
164
|
+
const presented = Buffer.from(digest(token), 'utf8');
|
|
165
|
+
const stored = Buffer.from(row.token_hash, 'utf8');
|
|
166
|
+
if (presented.length !== stored.length || !timingSafeEqual(presented, stored))
|
|
167
|
+
return undefined;
|
|
168
|
+
if (row.revoked_at !== null)
|
|
169
|
+
return undefined;
|
|
170
|
+
if (row.expires_at.getTime() <= this.clock.getTime())
|
|
171
|
+
return undefined;
|
|
172
|
+
// Everything from here is one transaction, serialized on the family.
|
|
173
|
+
//
|
|
174
|
+
// The read above is diagnosis, not the decision. Checking `used_at` in
|
|
175
|
+
// application code and writing it later is a read-modify-write, and under
|
|
176
|
+
// concurrency every request reads null before any of them writes: eight
|
|
177
|
+
// simultaneous redemptions of one stolen token all succeeded, each with a
|
|
178
|
+
// fresh session, and the reuse detection never fired. Rotation without an
|
|
179
|
+
// atomic claim detects nothing — it only makes theft quieter.
|
|
180
|
+
//
|
|
181
|
+
// `used_at IS NULL` in the WHERE clause is the claim, and the advisory lock
|
|
182
|
+
// is what makes the revocation that follows a loss complete: without it a
|
|
183
|
+
// revoke whose statement began before this transaction committed cannot see
|
|
184
|
+
// the row inserted here, so the token the winner just obtained would
|
|
185
|
+
// survive the revocation meant to catch it.
|
|
186
|
+
const outcome = await withOrg(this.deps.pool, row.org_id, async (client) => {
|
|
187
|
+
await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [row.family_id]);
|
|
188
|
+
const claimed = await client.query(`UPDATE refresh_tokens SET used_at = now()
|
|
189
|
+
WHERE id = $1 AND used_at IS NULL AND revoked_at IS NULL
|
|
190
|
+
RETURNING id`, [row.id]);
|
|
191
|
+
if (claimed.rows[0] === undefined)
|
|
192
|
+
return 'lost';
|
|
193
|
+
const { rows } = await client.query(`SELECT id, org_id, role, password_hash, disabled_at
|
|
194
|
+
FROM users WHERE id = $1 AND org_id = $2`, [row.user_id, row.org_id]);
|
|
195
|
+
const user = rows[0];
|
|
196
|
+
if (user === undefined || user.disabled_at !== null)
|
|
197
|
+
return 'gone';
|
|
198
|
+
return this.issue(user, row.family_id, client);
|
|
199
|
+
}, this.scope);
|
|
200
|
+
if (outcome === 'lost') {
|
|
201
|
+
// Someone already spent this one. Two parties hold the same token and
|
|
202
|
+
// there is no way to tell which is genuine, so the session ends for both.
|
|
203
|
+
await this.revokeFamily(row.org_id, row.family_id);
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
if (outcome === 'gone') {
|
|
207
|
+
// Disabling an account has to end its sessions, or it only stops new
|
|
208
|
+
// ones. The claim above is committed, so the token is spent either way —
|
|
209
|
+
// a refusal here must not leave it redeemable.
|
|
210
|
+
await this.revokeFamily(row.org_id, row.family_id);
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
return outcome;
|
|
214
|
+
}
|
|
215
|
+
/** Sign out. Ends the family, not just this token. */
|
|
216
|
+
async logout(token) {
|
|
217
|
+
const row = await whileAuthenticating(this.deps.pool, async (client) => {
|
|
218
|
+
const { rows } = await client.query('SELECT org_id, family_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL', [digest(token)]);
|
|
219
|
+
return rows[0];
|
|
220
|
+
}, this.scope);
|
|
221
|
+
if (row === undefined)
|
|
222
|
+
return false;
|
|
223
|
+
await this.revokeFamily(row.org_id, row.family_id);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* End every token descended from one login.
|
|
228
|
+
*
|
|
229
|
+
* Takes the same advisory lock the rotation path takes, and that is what
|
|
230
|
+
* makes it complete rather than approximately complete. Without it the two
|
|
231
|
+
* interleave badly: a statement's snapshot is fixed when the statement
|
|
232
|
+
* starts, so a revoke that begins before a rotation commits will not see the
|
|
233
|
+
* row that rotation inserts — and the token the racing party just obtained
|
|
234
|
+
* survives the revocation that was supposed to catch it. Serialized on the
|
|
235
|
+
* family, the revoke either runs first (and the rotation then finds the
|
|
236
|
+
* family revoked) or runs second (and sees the new row).
|
|
237
|
+
*/
|
|
238
|
+
async revokeFamily(orgId, familyId) {
|
|
239
|
+
await withOrg(this.deps.pool, orgId, async (client) => {
|
|
240
|
+
await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [familyId]);
|
|
241
|
+
await client.query('UPDATE refresh_tokens SET revoked_at = now() WHERE family_id = $1 AND revoked_at IS NULL', [familyId]);
|
|
242
|
+
}, this.scope);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Mint an access token and the refresh token that succeeds it.
|
|
246
|
+
*
|
|
247
|
+
* `family` carries across a rotation so the whole chain from one login can be
|
|
248
|
+
* revoked together; a fresh login starts a new one, so signing out of one
|
|
249
|
+
* device does not sign out of the others.
|
|
250
|
+
*/
|
|
251
|
+
async issue(user, family, client) {
|
|
252
|
+
const ttl = Math.max(ACCESS_MIN_SECONDS, this.deps.accessTokenTtl);
|
|
253
|
+
const now = this.clock;
|
|
254
|
+
const accessToken = await new SignJWT({
|
|
255
|
+
// From the row that authenticated, never from the request. This is the
|
|
256
|
+
// line invariant I1 is about.
|
|
257
|
+
org: user.org_id,
|
|
258
|
+
principal_type: 'user',
|
|
259
|
+
role: user.role,
|
|
260
|
+
})
|
|
261
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
262
|
+
.setSubject(user.id)
|
|
263
|
+
.setIssuer(this.deps.issuer)
|
|
264
|
+
.setAudience(this.deps.audience)
|
|
265
|
+
.setIssuedAt(Math.floor(now.getTime() / 1000))
|
|
266
|
+
.setExpirationTime(Math.floor(now.getTime() / 1000) + ttl)
|
|
267
|
+
.sign(this.deps.key);
|
|
268
|
+
const refreshToken = mint();
|
|
269
|
+
const expiresAt = new Date(now.getTime() + this.deps.refreshTokenTtl * 1000);
|
|
270
|
+
const insert = (client) => client.query(`INSERT INTO refresh_tokens (org_id, user_id, token_hash, family_id, expires_at)
|
|
271
|
+
VALUES ($1, $2, $3, COALESCE($4::uuid, gen_random_uuid()), $5)`, [user.org_id, user.id, digest(refreshToken), family ?? null, expiresAt]);
|
|
272
|
+
// On a rotation the caller hands in its own transaction, so the claim and
|
|
273
|
+
// the token that replaces it commit together — a crash between them would
|
|
274
|
+
// otherwise spend a token and issue nothing, ending a session for no
|
|
275
|
+
// reason. A fresh login has no transaction to join and opens its own.
|
|
276
|
+
if (client !== undefined)
|
|
277
|
+
await insert(client);
|
|
278
|
+
else
|
|
279
|
+
await withOrg(this.deps.pool, user.org_id, insert, this.scope);
|
|
280
|
+
return { accessToken, refreshToken, expiresIn: ttl, orgId: user.org_id, userId: user.id };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
//# sourceMappingURL=login.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login.js","sourceRoot":"","sources":["../src/login.ts"],"names":[],"mappings":"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"}
|
package/dist/main.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { collectDatabaseGauges, createMetrics, createPool, loadConfig, Registry, VectorStore,
|
|
3
|
-
import { HttpEmbedder, NacreIngest, NacreSearchService, PostgresAudit, PostgresDocuments, PostgresGrants, PostgresJobs, PostgresLayers, } from './adapters.js';
|
|
2
|
+
import { collectDatabaseGauges, createMetrics, createPool, loadConfig, loadJwtKeys, protectedResourceMetadata, Redis, Registry, VectorStore, withOrg, ConfigError, installGuards, onListenError, } from '@nacre.work/core';
|
|
3
|
+
import { HttpEmbedder, NacreIngest, NacreSearchService, PostgresAudit, PostgresAuditReader, PostgresDocuments, PostgresGrants, PostgresJobs, PostgresLayers, PostgresReindex, PostgresWorkspaces, } from './adapters.js';
|
|
4
|
+
import { Idempotency } from './idempotency.js';
|
|
5
|
+
import { Login } from './login.js';
|
|
6
|
+
import { rerankerFor } from './rerank.js';
|
|
7
|
+
import { RateLimiter } from './limits.js';
|
|
4
8
|
import { PostgresServiceAccounts, PostgresServiceKeys } from './service-keys.js';
|
|
5
9
|
import { createApi } from './server.js';
|
|
6
10
|
/**
|
|
@@ -11,70 +15,140 @@ import { createApi } from './server.js';
|
|
|
11
15
|
* below this line has a fallback.
|
|
12
16
|
*/
|
|
13
17
|
const APP_ROLE = 'nacre_app';
|
|
14
|
-
function jwtKey(config) {
|
|
15
|
-
// Development uses a symmetric secret. Production is meant to load an Ed25519
|
|
16
|
-
// key through NACRE_JWT_PRIVATE_KEY_REF; until that lands, refusing is the
|
|
17
|
-
// honest behaviour — a hardcoded fallback here would be a signing key anyone
|
|
18
|
-
// reading the source can forge tokens with.
|
|
19
|
-
const secret = process.env.NACRE_JWT_SECRET;
|
|
20
|
-
if (secret === undefined || secret.length < 32) {
|
|
21
|
-
throw new ConfigError([
|
|
22
|
-
'NACRE_JWT_SECRET is not set, or is shorter than 32 bytes. ' +
|
|
23
|
-
'Asymmetric keys through NACRE_JWT_PRIVATE_KEY_REF are not implemented yet; ' +
|
|
24
|
-
'until they are, this is required and there is no default.',
|
|
25
|
-
]);
|
|
26
|
-
}
|
|
27
|
-
void config;
|
|
28
|
-
return new TextEncoder().encode(secret);
|
|
29
|
-
}
|
|
30
18
|
async function main() {
|
|
31
19
|
const config = loadConfig();
|
|
32
|
-
const key =
|
|
20
|
+
const { key, alsoAccept } = loadJwtKeys();
|
|
33
21
|
const pool = createPool({ connectionString: config.pgUrl, max: config.pgPoolMax });
|
|
34
22
|
const vectors = new VectorStore(config.qdrantApiKey === undefined
|
|
35
23
|
? { url: config.qdrantUrl }
|
|
36
24
|
: { url: config.qdrantUrl, apiKey: config.qdrantApiKey });
|
|
37
|
-
const embedder = new HttpEmbedder(config.embeddingEndpoint, config.embeddingModel, config.embeddingDim);
|
|
38
|
-
// Slugs change rarely and are read on every search. A tiny cache keyed on the
|
|
39
|
-
// organization keeps a search from costing an extra round trip; it is not
|
|
40
|
-
// permission data, so staleness here cannot widen access.
|
|
41
|
-
const slugs = new Map();
|
|
42
|
-
const orgSlug = async (orgId) => {
|
|
43
|
-
const cached = slugs.get(orgId);
|
|
44
|
-
if (cached !== undefined)
|
|
45
|
-
return cached;
|
|
46
|
-
const found = await withOrg(pool, orgId, async (client) => {
|
|
47
|
-
const { rows } = await client.query('SELECT slug FROM organizations WHERE id = $1 AND deleted_at IS NULL', [orgId]);
|
|
48
|
-
return rows[0]?.slug;
|
|
49
|
-
}, { role: APP_ROLE });
|
|
50
|
-
if (found !== undefined)
|
|
51
|
-
slugs.set(orgId, found);
|
|
52
|
-
return found;
|
|
53
|
-
};
|
|
54
25
|
const registry = new Registry();
|
|
55
26
|
const metrics = createMetrics(registry);
|
|
56
27
|
registry.collect(collectDatabaseGauges(pool, metrics, APP_ROLE));
|
|
28
|
+
// Redis has been required configuration, and in every Compose profile with
|
|
29
|
+
// the API waiting on its healthcheck, since before anything connected to it.
|
|
30
|
+
// Rate limiting and Idempotency-Key are what it was declared for.
|
|
31
|
+
const redis = new Redis({ url: config.redisUrl });
|
|
32
|
+
const limitPolicies = {
|
|
33
|
+
search: { limit: config.rateSearchPerMin, windowSeconds: 60 },
|
|
34
|
+
ingest: { limit: config.rateIngestPerHour, windowSeconds: 3600 },
|
|
35
|
+
// Per address rather than per organization — there is no organization until
|
|
36
|
+
// the sign-in succeeds, and what this defends is one account's password.
|
|
37
|
+
login: { limit: config.rateLoginPer15Min, windowSeconds: 900 },
|
|
38
|
+
// And per client, which is what the address limit does not do: a spray
|
|
39
|
+
// across ten thousand addresses never repeats a key. Looser on purpose,
|
|
40
|
+
// because a whole office behind one NAT is one source here.
|
|
41
|
+
login_source: { limit: config.rateLoginSourcePer15Min, windowSeconds: 900 },
|
|
42
|
+
};
|
|
43
|
+
const limits = new RateLimiter({
|
|
44
|
+
redis,
|
|
45
|
+
policies: limitPolicies,
|
|
46
|
+
onDegraded: (resource, error) => {
|
|
47
|
+
// Allowed through, and said so. A rate limit is availability protection
|
|
48
|
+
// rather than an authorization control, so failing closed here would
|
|
49
|
+
// trade a rare over-serve for a certain outage — the opposite of the
|
|
50
|
+
// rule for permissions, and deliberately so.
|
|
51
|
+
console.warn(JSON.stringify({
|
|
52
|
+
msg: 'rate limit check unavailable; request allowed',
|
|
53
|
+
resource,
|
|
54
|
+
error: String(error).slice(0, 200),
|
|
55
|
+
}));
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
// Undefined unless the deployment configured one — `minimal` has no
|
|
59
|
+
// reranker by definition, which is what keeps it runnable without a GPU.
|
|
60
|
+
const reranker = rerankerFor(config);
|
|
61
|
+
const idempotency = new Idempotency({
|
|
62
|
+
redis,
|
|
63
|
+
onDegraded: (error) => {
|
|
64
|
+
console.warn(JSON.stringify({
|
|
65
|
+
msg: 'idempotency cache unavailable; request processed uncached',
|
|
66
|
+
error: String(error).slice(0, 200),
|
|
67
|
+
}));
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const login = new Login({
|
|
71
|
+
pool,
|
|
72
|
+
key,
|
|
73
|
+
issuer: config.jwtIssuer,
|
|
74
|
+
audience: config.jwtAudience,
|
|
75
|
+
accessTokenTtl: config.accessTokenTtl,
|
|
76
|
+
refreshTokenTtl: config.refreshTokenTtl,
|
|
77
|
+
role: APP_ROLE,
|
|
78
|
+
});
|
|
79
|
+
// What this process cannot serve a request without. S3 is in the documented
|
|
80
|
+
// list and is not checked, because nothing in the tree reads it yet — a probe
|
|
81
|
+
// that reported on a dependency the code never uses would be reporting on
|
|
82
|
+
// nothing. The embedder is not checked either: it is an external endpoint an
|
|
83
|
+
// operator supplies, a search fails loudly without it, and making readiness
|
|
84
|
+
// depend on somebody else's uptime turns their outage into a rollout that
|
|
85
|
+
// never completes.
|
|
86
|
+
const ready = async () => {
|
|
87
|
+
const [postgres, qdrant, redisUp] = await Promise.all([
|
|
88
|
+
withOrg(pool, '00000000-0000-0000-0000-000000000000', async (c) => {
|
|
89
|
+
await c.query('SELECT 1');
|
|
90
|
+
return true;
|
|
91
|
+
}, { role: APP_ROLE }).catch(() => false),
|
|
92
|
+
vectors.ready().catch(() => false),
|
|
93
|
+
redis.ping().catch(() => false),
|
|
94
|
+
]);
|
|
95
|
+
return { postgres, qdrant, redis: redisUp };
|
|
96
|
+
};
|
|
57
97
|
const server = createApi({
|
|
58
98
|
verify: {
|
|
59
99
|
key,
|
|
100
|
+
// Empty outside a rotation. During one it carries the key being retired,
|
|
101
|
+
// so tokens already in the wild keep verifying until they expire.
|
|
102
|
+
...(alsoAccept.length === 0 ? {} : { alsoAccept }),
|
|
60
103
|
issuer: config.jwtIssuer,
|
|
61
104
|
audience: config.jwtAudience,
|
|
62
105
|
serviceKeys: new PostgresServiceKeys(pool, APP_ROLE),
|
|
63
106
|
},
|
|
64
107
|
metrics: registry,
|
|
65
|
-
|
|
108
|
+
resourceMetadata: protectedResourceMetadata({
|
|
109
|
+
canonicalUrl: config.canonicalUrl,
|
|
110
|
+
...(config.oauthAuthorizationServer === ''
|
|
111
|
+
? {}
|
|
112
|
+
: { authorizationServer: config.oauthAuthorizationServer }),
|
|
113
|
+
}),
|
|
114
|
+
// The request path writes what it measures. Four of these were registered
|
|
115
|
+
// and never written, so /metrics served zeros forever — which reads as
|
|
116
|
+
// health rather than as absence.
|
|
117
|
+
observe: metrics,
|
|
118
|
+
ready,
|
|
119
|
+
maxBodyBytes: config.maxDocumentBytes,
|
|
120
|
+
limits,
|
|
121
|
+
limitPolicies,
|
|
122
|
+
trustProxy: config.trustProxy,
|
|
123
|
+
...(config.metricsToken === undefined ? {} : { metricsToken: config.metricsToken }),
|
|
124
|
+
idempotency,
|
|
125
|
+
login,
|
|
126
|
+
auditReader: new PostgresAuditReader(pool, APP_ROLE),
|
|
127
|
+
reindex: new PostgresReindex(pool, vectors, APP_ROLE),
|
|
128
|
+
documents: new PostgresDocuments(pool, vectors, APP_ROLE),
|
|
66
129
|
search: new NacreSearchService({
|
|
67
130
|
pool,
|
|
68
131
|
vectors,
|
|
69
|
-
|
|
70
|
-
orgSlug,
|
|
71
|
-
vectorName: vectorName(config.embeddingModel, config.embeddingDim),
|
|
132
|
+
embedderFor: HttpEmbedder.pool(),
|
|
72
133
|
role: APP_ROLE,
|
|
134
|
+
...(reranker === undefined ? {} : { reranker }),
|
|
135
|
+
rerankCandidates: config.rerankCandidates,
|
|
136
|
+
onRerankFailed: (error) => {
|
|
137
|
+
// Answered in fusion order rather than not at all. Reranking decides
|
|
138
|
+
// ordering over candidates the index already filtered by permission,
|
|
139
|
+
// so this is a quality degradation and not a permissions one — and an
|
|
140
|
+
// operator who turned it on is entitled to know it is not running.
|
|
141
|
+
console.warn(JSON.stringify({
|
|
142
|
+
msg: 'reranking failed; results are in fusion order',
|
|
143
|
+
error: String(error).slice(0, 200),
|
|
144
|
+
}));
|
|
145
|
+
},
|
|
73
146
|
}),
|
|
74
|
-
ingest: new NacreIngest({ pool, tombstone: vectors,
|
|
147
|
+
ingest: new NacreIngest({ pool, tombstone: vectors, role: APP_ROLE }),
|
|
75
148
|
audit: new PostgresAudit(pool, APP_ROLE),
|
|
76
149
|
jobs: new PostgresJobs(pool, APP_ROLE),
|
|
77
|
-
layers: new PostgresLayers(pool, APP_ROLE),
|
|
150
|
+
layers: new PostgresLayers(pool, vectors, APP_ROLE),
|
|
151
|
+
workspaces: new PostgresWorkspaces(pool, APP_ROLE),
|
|
78
152
|
grants: new PostgresGrants(pool, APP_ROLE),
|
|
79
153
|
serviceAccounts: new PostgresServiceAccounts(pool, APP_ROLE),
|
|
80
154
|
});
|
|
@@ -82,25 +156,34 @@ async function main() {
|
|
|
82
156
|
server.listen(port, () => {
|
|
83
157
|
// The fingerprint, never the secret. An operator needs to know which key is
|
|
84
158
|
// in use when two environments disagree; nobody needs the key in a log.
|
|
85
|
-
const
|
|
159
|
+
const print = (k) => `sha256:${createHash('sha256').update(k).digest('hex').slice(0, 12)}`;
|
|
86
160
|
console.log(JSON.stringify({
|
|
87
161
|
msg: 'api listening',
|
|
88
162
|
port,
|
|
89
163
|
env: config.env,
|
|
90
164
|
issuer: config.jwtIssuer,
|
|
91
|
-
jwt_key:
|
|
165
|
+
jwt_key: print(key),
|
|
166
|
+
// Present only during a rotation, which is exactly when an operator is
|
|
167
|
+
// reading this line. Its absence is how they know the rotation is
|
|
168
|
+
// finished and the old key is out.
|
|
169
|
+
...(alsoAccept.length === 0 ? {} : { jwt_key_previous: alsoAccept.map(print) }),
|
|
92
170
|
}));
|
|
93
171
|
});
|
|
94
|
-
|
|
95
|
-
|
|
172
|
+
onListenError(server, 'api', port);
|
|
173
|
+
installGuards({
|
|
174
|
+
service: 'api',
|
|
96
175
|
// Stop accepting, let in-flight requests finish, then release the pool.
|
|
97
|
-
// Dropping a request mid-flight would leave its audit event unwritten
|
|
98
|
-
server.close
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
176
|
+
// Dropping a request mid-flight would leave its audit event unwritten —
|
|
177
|
+
// but `server.close` waits without a bound, so `installGuards` puts one on
|
|
178
|
+
// it: one request stuck on a slow dependency used to mean the callback
|
|
179
|
+
// never ran, the pool was never released, and the orchestrator SIGKILLed at
|
|
180
|
+
// the end of its grace period. Every rolling deploy was an abrupt one.
|
|
181
|
+
shutdown: async () => {
|
|
182
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
183
|
+
redis.close();
|
|
184
|
+
await pool.end();
|
|
185
|
+
},
|
|
186
|
+
});
|
|
104
187
|
}
|
|
105
188
|
main().catch((error) => {
|
|
106
189
|
if (error instanceof ConfigError) {
|
package/dist/main.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAExC,OAAO,EACL,qBAAqB,EACrB,aAAa,EACb,UAAU,EACV,UAAU,EACV,QAAQ,EACR,WAAW,EACX,
|
|
1
|
+
{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAExC,OAAO,EACL,qBAAqB,EACrB,aAAa,EACb,UAAU,EACV,UAAU,EACV,WAAW,EACX,yBAAyB,EACzB,KAAK,EACL,QAAQ,EACR,WAAW,EACX,OAAO,EACP,WAAW,EACX,aAAa,EACb,aAAa,GACd,MAAM,kBAAkB,CAAA;AAEzB,OAAO,EACL,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,aAAa,EACb,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,YAAY,EACZ,cAAc,EACd,eAAe,EACf,kBAAkB,GACnB,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,EAAE,WAAW,EAAmC,MAAM,aAAa,CAAA;AAC1E,OAAO,EAAE,uBAAuB,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AAChF,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAEvC;;;;;;GAMG;AAEH,MAAM,QAAQ,GAAG,WAAW,CAAA;AAE5B,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;IAC3B,MAAM,EAAE,GAAG,EAAE,UAAU,EAAE,GAAG,WAAW,EAAE,CAAA;IAEzC,MAAM,IAAI,GAAG,UAAU,CAAC,EAAE,gBAAgB,EAAE,MAAM,CAAC,KAAK,EAAE,GAAG,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAAA;IAClF,MAAM,OAAO,GAAG,IAAI,WAAW,CAC7B,MAAM,CAAC,YAAY,KAAK,SAAS;QAC/B,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,SAAS,EAAE;QAC3B,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,YAAY,EAAE,CAC3D,CAAA;IACD,MAAM,QAAQ,GAAG,IAAI,QAAQ,EAAE,CAAA;IAC/B,MAAM,OAAO,GAAG,aAAa,CAAC,QAAQ,CAAC,CAAA;IACvC,QAAQ,CAAC,OAAO,CAAC,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAA;IAEhE,2EAA2E;IAC3E,6EAA6E;IAC7E,kEAAkE;IAClE,MAAM,KAAK,GAAG,IAAI,KAAK,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAA;IAEjD,MAAM,aAAa,GAAkC;QACnD,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,gBAAgB,EAAE,aAAa,EAAE,EAAE,EAAE;QAC7D,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,iBAAiB,EAAE,aAAa,EAAE,IAAI,EAAE;QAChE,4EAA4E;QAC5E,yEAAyE;QACzE,KAAK,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,iBAAiB,EAAE,aAAa,EAAE,GAAG,EAAE;QAC9D,uEAAuE;QACvE,wEAAwE;QACxE,4DAA4D;QAC5D,YAAY,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,uBAAuB,EAAE,aAAa,EAAE,GAAG,EAAE;KAC5E,CAAA;IAED,MAAM,MAAM,GAAG,IAAI,WAAW,CAAC;QAC7B,KAAK;QACL,QAAQ,EAAE,aAAa;QACvB,UAAU,EAAE,CAAC,QAAQ,EAAE,KAAK,EAAE,EAAE;YAC9B,wEAAwE;YACxE,qEAAqE;YACrE,qEAAqE;YACrE,6CAA6C;YAC7C,OAAO,CAAC,IAAI,CACV,IAAI,CAAC,SAAS,CAAC;gBACb,GAAG,EAAE,+CAA+C;gBACpD,QAAQ;gBACR,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC;aACnC,CAAC,CACH,CAAA;QACH,CAAC;KACF,CAAC,CAAA;IAEF,oEAAoE;IACpE,yEAAyE;IACzE,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM,CAAC,CAAA;IAEpC,MAAM,WAAW,GAAG,IAAI,WAAW,CAAC;QAClC,KAAK;QACL,UAAU,EAAE,CAAC,KAAK,EAAE,EAAE;YACpB,OAAO,CAAC,IAAI,CACV,IAAI,CAAC,SAAS,CAAC;gBACb,GAAG,EAAE,2DAA2D;gBAChE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC;aACnC,CAAC,CACH,CAAA;QACH,CAAC;KACF,CAAC,CAAA;IAEF,MAAM,KAAK,GAAG,IAAI,KAAK,CAAC;QACtB,IAAI;QACJ,GAAG;QACH,MAAM,EAAE,MAAM,CAAC,SAAS;QACxB,QAAQ,EAAE,MAAM,CAAC,WAAW;QAC5B,cAAc,EAAE,MAAM,CAAC,cAAc;QACrC,eAAe,EAAE,MAAM,CAAC,eAAe;QACvC,IAAI,EAAE,QAAQ;KACf,CAAC,CAAA;IAEF,4EAA4E;IAC5E,8EAA8E;IAC9E,0EAA0E;IAC1E,6EAA6E;IAC7E,4EAA4E;IAC5E,0EAA0E;IAC1E,mBAAmB;IACnB,MAAM,KAAK,GAAG,KAAK,IAAsC,EAAE;QACzD,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;YACpD,OAAO,CAAC,IAAI,EAAE,sCAAsC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;gBAChE,MAAM,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;gBACzB,OAAO,IAAI,CAAA;YACb,CAAC,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC;YACzC,OAAO,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC;YAClC,KAAK,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC;SAChC,CAAC,CAAA;QACF,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,CAAA;IAC7C,CAAC,CAAA;IAED,MAAM,MAAM,GAAG,SAAS,CAAC;QACvB,MAAM,EAAE;YACN,GAAG;YACH,yEAAyE;YACzE,kEAAkE;YAClE,GAAG,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC;YAClD,MAAM,EAAE,MAAM,CAAC,SAAS;YACxB,QAAQ,EAAE,MAAM,CAAC,WAAW;YAC5B,WAAW,EAAE,IAAI,mBAAmB,CAAC,IAAI,EAAE,QAAQ,CAAC;SACrD;QACD,OAAO,EAAE,QAAQ;QACjB,gBAAgB,EAAE,yBAAyB,CAAC;YAC1C,YAAY,EAAE,MAAM,CAAC,YAAY;YACjC,GAAG,CAAC,MAAM,CAAC,wBAAwB,KAAK,EAAE;gBACxC,CAAC,CAAC,EAAE;gBACJ,CAAC,CAAC,EAAE,mBAAmB,EAAE,MAAM,CAAC,wBAAwB,EAAE,CAAC;SAC9D,CAAC;QACF,0EAA0E;QAC1E,uEAAuE;QACvE,iCAAiC;QACjC,OAAO,EAAE,OAAO;QAChB,KAAK;QACL,YAAY,EAAE,MAAM,CAAC,gBAAgB;QACrC,MAAM;QACN,aAAa;QACb,UAAU,EAAE,MAAM,CAAC,UAAU;QAC7B,GAAG,CAAC,MAAM,CAAC,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,MAAM,CAAC,YAAY,EAAE,CAAC;QACnF,WAAW;QACX,KAAK;QACL,WAAW,EAAE,IAAI,mBAAmB,CAAC,IAAI,EAAE,QAAQ,CAAC;QACpD,OAAO,EAAE,IAAI,eAAe,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC;QACrD,SAAS,EAAE,IAAI,iBAAiB,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC;QACzD,MAAM,EAAE,IAAI,kBAAkB,CAAC;YAC7B,IAAI;YACJ,OAAO;YACP,WAAW,EAAE,YAAY,CAAC,IAAI,EAAE;YAChC,IAAI,EAAE,QAAQ;YACd,GAAG,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC;YAC/C,gBAAgB,EAAE,MAAM,CAAC,gBAAgB;YACzC,cAAc,EAAE,CAAC,KAAK,EAAE,EAAE;gBACxB,qEAAqE;gBACrE,qEAAqE;gBACrE,sEAAsE;gBACtE,mEAAmE;gBACnE,OAAO,CAAC,IAAI,CACV,IAAI,CAAC,SAAS,CAAC;oBACb,GAAG,EAAE,+CAA+C;oBACpD,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC;iBACnC,CAAC,CACH,CAAA;YACH,CAAC;SACF,CAAC;QACF,MAAM,EAAE,IAAI,WAAW,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;QACrE,KAAK,EAAE,IAAI,aAAa,CAAC,IAAI,EAAE,QAAQ,CAAC;QACxC,IAAI,EAAE,IAAI,YAAY,CAAC,IAAI,EAAE,QAAQ,CAAC;QACtC,MAAM,EAAE,IAAI,cAAc,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC;QACnD,UAAU,EAAE,IAAI,kBAAkB,CAAC,IAAI,EAAE,QAAQ,CAAC;QAClD,MAAM,EAAE,IAAI,cAAc,CAAC,IAAI,EAAE,QAAQ,CAAC;QAC1C,eAAe,EAAE,IAAI,uBAAuB,CAAC,IAAI,EAAE,QAAQ,CAAC;KAC7D,CAAC,CAAA;IAEF,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAA;IAC7C,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;QACvB,4EAA4E;QAC5E,wEAAwE;QACxE,MAAM,KAAK,GAAG,CAAC,CAAa,EAAE,EAAE,CAAC,UAAU,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,CAAA;QACtG,OAAO,CAAC,GAAG,CACT,IAAI,CAAC,SAAS,CAAC;YACb,GAAG,EAAE,eAAe;YACpB,IAAI;YACJ,GAAG,EAAE,MAAM,CAAC,GAAG;YACf,MAAM,EAAE,MAAM,CAAC,SAAS;YACxB,OAAO,EAAE,KAAK,CAAC,GAAG,CAAC;YACnB,uEAAuE;YACvE,kEAAkE;YAClE,mCAAmC;YACnC,GAAG,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;SAChF,CAAC,CACH,CAAA;IACH,CAAC,CAAC,CAAA;IAEF,aAAa,CAAC,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,CAAA;IAElC,aAAa,CAAC;QACZ,OAAO,EAAE,KAAK;QACd,wEAAwE;QACxE,wEAAwE;QACxE,2EAA2E;QAC3E,uEAAuE;QACvE,4EAA4E;QAC5E,uEAAuE;QACvE,QAAQ,EAAE,KAAK,IAAI,EAAE;YACnB,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC,CAAA;YACnE,KAAK,CAAC,KAAK,EAAE,CAAA;YACb,MAAM,IAAI,CAAC,GAAG,EAAE,CAAA;QAClB,CAAC;KACF,CAAC,CAAA;AACJ,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;IAC9B,IAAI,KAAK,YAAY,WAAW,EAAE,CAAC;QACjC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;QAC5B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC;IACD,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,EAAE,iBAAiB,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAA;IAC/E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC,CAAC,CAAA"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type Problem } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cursor pagination, and never offset.
|
|
4
|
+
*
|
|
5
|
+
* `docs/api.md` forbids offset on two grounds and both are real. It breaks
|
|
6
|
+
* under concurrent inserts — a row added while a client pages through shifts
|
|
7
|
+
* everything after it, so the client sees an item twice or not at all. And it
|
|
8
|
+
* invites enumeration: `?offset=10000` is a question about how much exists,
|
|
9
|
+
* which on a permission-scoped collection is a question the caller has no right
|
|
10
|
+
* to ask.
|
|
11
|
+
*
|
|
12
|
+
* The cursor is a position, not an index. It carries the sort key of the last
|
|
13
|
+
* item returned, so the next page is "everything after this one" — which is
|
|
14
|
+
* stable no matter what was inserted in between, and says nothing about size.
|
|
15
|
+
*
|
|
16
|
+
* ## Opaque, and only just
|
|
17
|
+
*
|
|
18
|
+
* It is base64url of `created_at|id`, which anyone can decode, and that is
|
|
19
|
+
* fine: both values are already in the response they came from. What matters is
|
|
20
|
+
* that it is *treated* as opaque — a client that parses it and constructs its
|
|
21
|
+
* own has built a dependency on a format that is allowed to change, and the
|
|
22
|
+
* signature check below is what tells them so before it becomes their problem.
|
|
23
|
+
*
|
|
24
|
+
* It is not signed. A forged cursor selects a different page of the same
|
|
25
|
+
* caller's own collection, which they could reach by paging anyway; signing it
|
|
26
|
+
* would defend nothing and imply a guarantee that does not exist.
|
|
27
|
+
*/
|
|
28
|
+
export declare const MAX_LIMIT = 200;
|
|
29
|
+
export declare const DEFAULT_LIMIT = 50;
|
|
30
|
+
export interface Position {
|
|
31
|
+
/** The sort key of the last item on the previous page. */
|
|
32
|
+
readonly createdAt: string;
|
|
33
|
+
/** Tie-breaker, because timestamps collide under a bulk insert. */
|
|
34
|
+
readonly id: string;
|
|
35
|
+
}
|
|
36
|
+
export interface Page {
|
|
37
|
+
readonly limit: number;
|
|
38
|
+
readonly after: Position | undefined;
|
|
39
|
+
}
|
|
40
|
+
export declare function encodeCursor(position: Position): string;
|
|
41
|
+
/**
|
|
42
|
+
* Read `?limit=` and `?cursor=`, or explain what is wrong with them.
|
|
43
|
+
*
|
|
44
|
+
* `?offset=` is refused rather than ignored. Ignoring it gives a client the
|
|
45
|
+
* first page over and over while they believe they are paging, which is the
|
|
46
|
+
* failure that takes an afternoon to see.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readPage(params: URLSearchParams, instance: string, requestId: string): Page | Problem;
|
|
49
|
+
/**
|
|
50
|
+
* What a paginated port returns.
|
|
51
|
+
*
|
|
52
|
+
* `items` and the cursor are separate because they are not always derived from
|
|
53
|
+
* the same row. Where a collection is filtered in application code after the
|
|
54
|
+
* database has already applied the cursor — grants are, because who may see one
|
|
55
|
+
* is decided by walking the scope tree — the cursor has to come from the last
|
|
56
|
+
* row **fetched** rather than the last one returned. Taking it from the last
|
|
57
|
+
* returned row would skip everything the filter removed after it.
|
|
58
|
+
*
|
|
59
|
+
* The consequence, which callers have to know: **a page may hold fewer than
|
|
60
|
+
* `limit` items, and an empty page does not mean the end.** `nextCursor` is the
|
|
61
|
+
* only signal that more exist.
|
|
62
|
+
*/
|
|
63
|
+
export interface PageResult<T> {
|
|
64
|
+
readonly items: readonly T[];
|
|
65
|
+
readonly nextCursor: string | null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The common case: rows come back already filtered, so the cursor is the last
|
|
69
|
+
* one returned and a short page means the collection is exhausted.
|
|
70
|
+
*/
|
|
71
|
+
export declare function pageOf<T>(rows: readonly T[], page: Page | undefined, positionOf: (row: T) => Position): PageResult<T>;
|
|
72
|
+
//# sourceMappingURL=pagination.d.ts.map
|