@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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* `Idempotency-Key`, cached for 24 hours.
|
|
4
|
+
*
|
|
5
|
+
* `POST /v1/documents` does not need this — it is idempotent on
|
|
6
|
+
* `(layer, external_id)` and the content hash, which is a stronger guarantee
|
|
7
|
+
* because it survives the cache expiring. This is for everything else unsafe:
|
|
8
|
+
* issuing a grant, revoking one, creating a service account. A client that
|
|
9
|
+
* times out and retries should not end up with two accounts.
|
|
10
|
+
*
|
|
11
|
+
* ## Scoped to the caller, not to the tenant
|
|
12
|
+
*
|
|
13
|
+
* The cache key mixes the key the caller sent with the organization, **the
|
|
14
|
+
* principal**, the method, the path, and a hash of the body. Three of those are
|
|
15
|
+
* not optional.
|
|
16
|
+
*
|
|
17
|
+
* The organization, because a key is a string the caller chose: without it,
|
|
18
|
+
* `Idempotency-Key: 1` from one tenant hands another tenant's response to
|
|
19
|
+
* whoever asks next.
|
|
20
|
+
*
|
|
21
|
+
* The principal, for the same reason one level down, and this one was missing.
|
|
22
|
+
* Two principals in one organization see different things — that is the whole
|
|
23
|
+
* product — so a cache scoped to the tenant let any of them replay any other's
|
|
24
|
+
* response, verbatim and unchecked, because a replay is sent before any handler
|
|
25
|
+
* runs. It bypassed the pre-filter (invariant 2), handed read results to a
|
|
26
|
+
* `write`-only service account (invariant 6), and kept serving a principal
|
|
27
|
+
* whose grant had been revoked for a further 24 hours (invariant 4).
|
|
28
|
+
*
|
|
29
|
+
* The body, because reusing a key with different content is a client bug, and
|
|
30
|
+
* the useful answer to it is `409` rather than silently replaying the first
|
|
31
|
+
* result. A caller who changes the payload and keeps the key is not asking for
|
|
32
|
+
* the old answer.
|
|
33
|
+
*
|
|
34
|
+
* ## Only responses, and only successful ones
|
|
35
|
+
*
|
|
36
|
+
* A failure is never stored. The point is to stop a retry repeating an
|
|
37
|
+
* **effect**, and a request that failed had none — while a cached `500` makes a
|
|
38
|
+
* transient fault permanent for a day and denies the retry the feature exists
|
|
39
|
+
* to serve.
|
|
40
|
+
*
|
|
41
|
+
* ## Failing open
|
|
42
|
+
*
|
|
43
|
+
* If Redis is unreachable the request is processed normally, uncached. The risk
|
|
44
|
+
* is a duplicate on a retry, which is what the feature exists to prevent — so
|
|
45
|
+
* this is a real degradation, logged as one. The alternative is refusing writes
|
|
46
|
+
* because a cache is down, which trades a rare duplicate for a certain outage.
|
|
47
|
+
*/
|
|
48
|
+
const TTL_SECONDS = 24 * 60 * 60;
|
|
49
|
+
/**
|
|
50
|
+
* How long a reservation lives before the key frees itself.
|
|
51
|
+
*
|
|
52
|
+
* The reservation and the stored response used to share the 24-hour TTL, which
|
|
53
|
+
* made a crash permanent: `begin` reserves with `SET NX EX 86400` and only
|
|
54
|
+
* `store` overwrites it, so a process that was OOM-killed or SIGKILLed between
|
|
55
|
+
* the two left `pending: true` sitting there for a full day — and every retry
|
|
56
|
+
* of that exact request got `409 "the first attempt has not finished"`. The one
|
|
57
|
+
* situation the feature exists to serve, made impossible by the feature, with
|
|
58
|
+
* `redis-cli DEL` as the only way out.
|
|
59
|
+
*
|
|
60
|
+
* Sixty seconds is longer than any request this API serves and short enough
|
|
61
|
+
* that a crash costs a minute. The response TTL is untouched: once there is a
|
|
62
|
+
* result to replay, it is worth keeping for the full day.
|
|
63
|
+
*/
|
|
64
|
+
const RESERVATION_SECONDS = 60;
|
|
65
|
+
/** Long enough that a collision is not a thing, short enough to read in a log. */
|
|
66
|
+
const digest = (value) => createHash('sha256').update(value).digest('base64url').slice(0, 32);
|
|
67
|
+
export const isReplay = (o) => 'cached' in o;
|
|
68
|
+
export const isConflict = (o) => 'conflict' in o;
|
|
69
|
+
/** Organization, then principal. Both, in that order, so neither can be crossed. */
|
|
70
|
+
const orgId = (p) => `${p.orgId}:${p.type}:${p.id}`;
|
|
71
|
+
export class Idempotency {
|
|
72
|
+
options;
|
|
73
|
+
constructor(options) {
|
|
74
|
+
this.options = options;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Look up a key, or reserve it.
|
|
78
|
+
*
|
|
79
|
+
* `SET NX` is what makes two concurrent retries safe: exactly one reserves
|
|
80
|
+
* the slot and runs the handler, and the other sees a reservation rather than
|
|
81
|
+
* a result. A reservation is reported as a conflict — the honest answer to
|
|
82
|
+
* "the first attempt is still in flight" is not to replay a response that
|
|
83
|
+
* does not exist yet.
|
|
84
|
+
*/
|
|
85
|
+
async begin(key, principal, method, path, body) {
|
|
86
|
+
const fingerprint = digest(JSON.stringify({ method, path, body: body ?? null }));
|
|
87
|
+
// The organization AND the principal, and the second one is not optional.
|
|
88
|
+
//
|
|
89
|
+
// Scoped to the organization alone, this was a cross-principal read: the
|
|
90
|
+
// key is a string the caller chose, so any principal in the tenant who
|
|
91
|
+
// presented the same key and the same body was handed whatever the first
|
|
92
|
+
// one got — replayed verbatim, with no permission check on the way out,
|
|
93
|
+
// because the cached response is sent before any handler runs.
|
|
94
|
+
//
|
|
95
|
+
// That is invariant 2 bypassed (the answer comes from a cache rather than
|
|
96
|
+
// from a filtered index traversal), invariant 6 bypassed (a `write`-only
|
|
97
|
+
// service account receives read results), and invariant 4 defeated for
|
|
98
|
+
// 24 hours (a principal whose grant was revoked replays their own key and
|
|
99
|
+
// keeps being served). One convenience feature, four rules.
|
|
100
|
+
const cacheKey = `nacre:idem:${orgId(principal)}:${digest(key)}`;
|
|
101
|
+
try {
|
|
102
|
+
const existing = await this.options.redis.command('GET', cacheKey);
|
|
103
|
+
if (typeof existing === 'string') {
|
|
104
|
+
const record = JSON.parse(existing);
|
|
105
|
+
if (record.fingerprint !== fingerprint)
|
|
106
|
+
return { conflict: true };
|
|
107
|
+
if (record.pending === true)
|
|
108
|
+
return { conflict: true };
|
|
109
|
+
return { cached: { status: record.status, body: record.body } };
|
|
110
|
+
}
|
|
111
|
+
const reserved = await this.options.redis.command('SET', cacheKey, JSON.stringify({ fingerprint, pending: true }), 'NX', 'EX',
|
|
112
|
+
// The reservation, not the answer. See RESERVATION_SECONDS.
|
|
113
|
+
String(RESERVATION_SECONDS));
|
|
114
|
+
// Somebody else reserved it between the GET and the SET.
|
|
115
|
+
if (reserved === null)
|
|
116
|
+
return { conflict: true };
|
|
117
|
+
return {
|
|
118
|
+
proceed: true,
|
|
119
|
+
store: async (status, responseBody) => {
|
|
120
|
+
// Only what succeeded. A cached failure is never useful and is often
|
|
121
|
+
// harmful: the point of this cache is to stop a retry repeating an
|
|
122
|
+
// *effect*, and a request that failed had none to repeat. Storing one
|
|
123
|
+
// makes a transient 500 permanent for 24 hours and turns the retry
|
|
124
|
+
// the feature exists to serve into the one thing it cannot do.
|
|
125
|
+
if (status >= 400) {
|
|
126
|
+
await this.options.redis.command('DEL', cacheKey).catch(() => undefined);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await this.options.redis.command('SET', cacheKey, JSON.stringify({ fingerprint, status, body: responseBody }), 'EX', String(TTL_SECONDS));
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
// The work is already done and the caller is getting their answer.
|
|
134
|
+
// Losing the cache entry means a retry redoes it, which is the
|
|
135
|
+
// situation before this feature existed.
|
|
136
|
+
this.options.onDegraded?.(error);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
this.options.onDegraded?.(error);
|
|
143
|
+
return { proceed: true, store: async () => { } };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=idempotency.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
export { createApi } from './server.js';
|
|
2
|
-
export type { ApiOptions, AuditEvent, AuditSink, DocumentView, Documents, GrantInput, GrantRecord, Grants, Ingest, IngestOutcome, IngestRequest, Job, Jobs, Layer, LayerOutcome, Layers, SearchHit, SearchService, ServiceAccountPort, ServiceAccountView, } from './server.js';
|
|
2
|
+
export type { ApiOptions, AuditEvent, AuditQuery, AuditReader, AuditRecord, Reindex, ReindexOutcome, ReindexStatus, AuditSink, DocumentView, Documents, GrantInput, GrantRecord, Grants, Ingest, IngestOutcome, IngestRequest, Job, Jobs, Layer, LayerOutcome, Layers, SearchHit, SearchOptions, SearchService, ServiceAccountPort, ServiceAccountView, } from './server.js';
|
|
3
3
|
export { authenticate, findTenantOverride, rejectTenantOverride } from './auth.js';
|
|
4
4
|
export type { AuthContext, VerifyOptions } from './auth.js';
|
|
5
5
|
export { Problem, badRequest, forbidden, internal, notFound, unauthorized } from './errors.js';
|
|
6
|
-
export { HttpEmbedder, NacreIngest, NacreSearchService, PostgresAudit, PostgresDocuments, PostgresGrants, PostgresJobs, PostgresLayers, } from './adapters.js';
|
|
6
|
+
export { HttpEmbedder, NacreIngest, NacreSearchService, PostgresAudit, PostgresAuditReader, PostgresDocuments, PostgresGrants, PostgresJobs, PostgresLayers, PostgresWorkspaces, PostgresReindex, } from './adapters.js';
|
|
7
7
|
export type { DocumentTombstone, Embedder, IngestDeps, SearchDeps } from './adapters.js';
|
|
8
|
+
export { applyRanking, HttpReranker, rerankerFor } from './rerank.js';
|
|
9
|
+
export type { Reranker } from './rerank.js';
|
|
8
10
|
export { generateKey, hashOf, KEY_PREFIX, looksLikeServiceKey, PostgresServiceAccounts, PostgresServiceKeys, prefixOf, } from './service-keys.js';
|
|
9
11
|
export type { ServiceAccount, ServiceAccounts, ServiceKeyResolver } from './service-keys.js';
|
|
12
|
+
export { auditFormat, auditJson, readAuditQuery, toCsv, toNdjson } from './audit-export.js';
|
|
13
|
+
export type { AuditFormat } from './audit-export.js';
|
|
14
|
+
export { limitHeaders, RateLimiter } from './limits.js';
|
|
15
|
+
export type { LimitDecision, LimitPolicy, Resource } from './limits.js';
|
|
16
|
+
export { clientSource } from './source.js';
|
|
17
|
+
export { Login } from './login.js';
|
|
18
|
+
export type { LoginDeps, LoginRequest, Tokens } from './login.js';
|
|
10
19
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,YAAY,EACV,UAAU,EACV,UAAU,EACV,SAAS,EACT,YAAY,EACZ,SAAS,EACT,UAAU,EACV,WAAW,EACX,MAAM,EACN,MAAM,EACN,aAAa,EACb,aAAa,EACb,GAAG,EACH,IAAI,EACJ,KAAK,EACL,YAAY,EACZ,MAAM,EACN,SAAS,EACT,aAAa,EACb,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAA;AAClF,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,WAAW,CAAA;AAC3D,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC9F,OAAO,EACL,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,YAAY,EACZ,cAAc,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,YAAY,EACV,UAAU,EACV,UAAU,EACV,UAAU,EACV,WAAW,EACX,WAAW,EACX,OAAO,EACP,cAAc,EACd,aAAa,EACb,SAAS,EACT,YAAY,EACZ,SAAS,EACT,UAAU,EACV,WAAW,EACX,MAAM,EACN,MAAM,EACN,aAAa,EACb,aAAa,EACb,GAAG,EACH,IAAI,EACJ,KAAK,EACL,YAAY,EACZ,MAAM,EACN,SAAS,EACT,aAAa,EACb,aAAa,EACb,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAA;AAClF,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,WAAW,CAAA;AAC3D,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC9F,OAAO,EACL,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,aAAa,EACb,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,YAAY,EACZ,cAAc,EACd,kBAAkB,EAClB,eAAe,GAChB,MAAM,eAAe,CAAA;AACtB,YAAY,EAAE,iBAAiB,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,eAAe,CAAA;AACxF,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACrE,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EACL,WAAW,EACX,MAAM,EACN,UAAU,EACV,mBAAmB,EACnB,uBAAuB,EACvB,mBAAmB,EACnB,QAAQ,GACT,MAAM,mBAAmB,CAAA;AAC1B,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAK5F,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,cAAc,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AAC3F,YAAY,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACvD,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AACvE,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
export { createApi } from './server.js';
|
|
2
2
|
export { authenticate, findTenantOverride, rejectTenantOverride } from './auth.js';
|
|
3
3
|
export { Problem, badRequest, forbidden, internal, notFound, unauthorized } from './errors.js';
|
|
4
|
-
export { HttpEmbedder, NacreIngest, NacreSearchService, PostgresAudit, PostgresDocuments, PostgresGrants, PostgresJobs, PostgresLayers, } from './adapters.js';
|
|
4
|
+
export { HttpEmbedder, NacreIngest, NacreSearchService, PostgresAudit, PostgresAuditReader, PostgresDocuments, PostgresGrants, PostgresJobs, PostgresLayers, PostgresWorkspaces, PostgresReindex, } from './adapters.js';
|
|
5
|
+
export { applyRanking, HttpReranker, rerankerFor } from './rerank.js';
|
|
5
6
|
export { generateKey, hashOf, KEY_PREFIX, looksLikeServiceKey, PostgresServiceAccounts, PostgresServiceKeys, prefixOf, } from './service-keys.js';
|
|
7
|
+
// The limiter, so the MCP transport can share it rather than growing a second
|
|
8
|
+
// one. Two limiters would be two buckets, and a caller out of budget on one
|
|
9
|
+
// surface would simply use the other — which is what happened before this was
|
|
10
|
+
// exported: NACRE_RATE_* applied to REST only.
|
|
11
|
+
export { auditFormat, auditJson, readAuditQuery, toCsv, toNdjson } from './audit-export.js';
|
|
12
|
+
export { limitHeaders, RateLimiter } from './limits.js';
|
|
13
|
+
export { clientSource } from './source.js';
|
|
14
|
+
export { Login } from './login.js';
|
|
6
15
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AA8BvC,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAA;AAElF,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC9F,OAAO,EACL,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,aAAa,EACb,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,YAAY,EACZ,cAAc,EACd,kBAAkB,EAClB,eAAe,GAChB,MAAM,eAAe,CAAA;AAEtB,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAErE,OAAO,EACL,WAAW,EACX,MAAM,EACN,UAAU,EACV,mBAAmB,EACnB,uBAAuB,EACvB,mBAAmB,EACnB,QAAQ,GACT,MAAM,mBAAmB,CAAA;AAE1B,8EAA8E;AAC9E,4EAA4E;AAC5E,8EAA8E;AAC9E,+CAA+C;AAC/C,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,cAAc,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AAE3F,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEvD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA"}
|
package/dist/init.d.ts
CHANGED
|
@@ -25,6 +25,15 @@ export interface Options {
|
|
|
25
25
|
readonly name: string;
|
|
26
26
|
readonly email: string;
|
|
27
27
|
readonly workspace: string;
|
|
28
|
+
/**
|
|
29
|
+
* Set on the administrator, when there is one to set.
|
|
30
|
+
*
|
|
31
|
+
* Passed in already hashed rather than as a plaintext to hash here: `init`
|
|
32
|
+
* runs in a terminal and its arguments end up in a shell history and in
|
|
33
|
+
* `ps`, so the password is generated rather than accepted, and the plaintext
|
|
34
|
+
* exists in exactly one place — the line printed at the end.
|
|
35
|
+
*/
|
|
36
|
+
readonly passwordHash?: string;
|
|
28
37
|
}
|
|
29
38
|
export declare function parseArgs(argv: readonly string[]): Options | string;
|
|
30
39
|
export interface InitResult {
|
package/dist/init.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,IAAI,CAAA;AAI9B;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B;;;;;;;OAOG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAC/B;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,GAAG,MAAM,CA6BnE;AAMD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAC1B;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,EACjE,UAAU,EAAE,MAAM,EAClB,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAsB,GAC5E,OAAO,CAAC,UAAU,CAAC,CA2FrB"}
|
package/dist/init.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { collectionName, ConfigError, createPool, loadConfig, vectorName, VectorStore, } from '@nacre.work/core';
|
|
1
|
+
import { collectionName, ConfigError, createPool, hashPassword, loadConfig, vectorName, VectorStore, } from '@nacre.work/core';
|
|
2
2
|
import { SignJWT } from 'jose';
|
|
3
|
+
import { randomInt } from 'node:crypto';
|
|
3
4
|
import { pathToFileURL } from 'node:url';
|
|
4
5
|
export function parseArgs(argv) {
|
|
5
6
|
const values = new Map();
|
|
@@ -64,9 +65,15 @@ export async function initialize(pool, options, provider, collection, log = () =
|
|
|
64
65
|
// the organization in the transaction-local setting. The organizations row
|
|
65
66
|
// itself is not covered — it is the tenant registry, not tenant data.
|
|
66
67
|
await client.query('SELECT set_config($1, $2, true)', ['app.current_org', org.id]);
|
|
67
|
-
const { rows: users } = await client.query(
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const { rows: users } = await client.query(
|
|
69
|
+
// The password is set only when this run generated one — re-running init
|
|
70
|
+
// against an existing organization must not silently reset the
|
|
71
|
+
// administrator's password and lock them out of their own installation.
|
|
72
|
+
`INSERT INTO users (org_id, email, role, password_hash) VALUES ($1,$2,'org_admin',$3)
|
|
73
|
+
ON CONFLICT (org_id, email) DO UPDATE
|
|
74
|
+
SET email = EXCLUDED.email,
|
|
75
|
+
password_hash = COALESCE(users.password_hash, EXCLUDED.password_hash)
|
|
76
|
+
RETURNING id`, [org.id, options.email, options.passwordHash ?? null]);
|
|
70
77
|
const userId = users[0]?.id;
|
|
71
78
|
if (userId === undefined)
|
|
72
79
|
throw new Error('the user insert returned no row');
|
|
@@ -115,6 +122,30 @@ export async function initialize(pool, options, provider, collection, log = () =
|
|
|
115
122
|
client.release();
|
|
116
123
|
}
|
|
117
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Six words and a number, from the CSPRNG.
|
|
127
|
+
*
|
|
128
|
+
* Roughly 70 bits with this list, which is more than a person would choose and
|
|
129
|
+
* still something they can read off a terminal and type into a form once. A
|
|
130
|
+
* base64 string of the same strength gets copied wrong, and the recovery from
|
|
131
|
+
* that is re-running init against a live installation.
|
|
132
|
+
*/
|
|
133
|
+
function generatePassword() {
|
|
134
|
+
const words = [
|
|
135
|
+
'abalone', 'anchor', 'aurora', 'basalt', 'beacon', 'bramble', 'cinder', 'clover',
|
|
136
|
+
'compass', 'coral', 'crest', 'current', 'delta', 'drift', 'ember', 'estuary',
|
|
137
|
+
'fathom', 'ferry', 'fjord', 'granite', 'harbor', 'haven', 'iris', 'kelp',
|
|
138
|
+
'lantern', 'ledger', 'lichen', 'mantle', 'marlin', 'meadow', 'mica', 'nacre',
|
|
139
|
+
'nautilus', 'obsidian', 'onyx', 'opal', 'orbit', 'pearl', 'pelagic', 'pillar',
|
|
140
|
+
'plover', 'quarry', 'quartz', 'reef', 'ripple', 'salt', 'sextant', 'shale',
|
|
141
|
+
'shoal', 'silt', 'slate', 'spindrift', 'stratum', 'tide', 'trawler', 'trench',
|
|
142
|
+
'vellum', 'wharf', 'willow', 'zephyr',
|
|
143
|
+
];
|
|
144
|
+
// randomInt rather than Math.random or a modulo of randomBytes: one is not a
|
|
145
|
+
// CSPRNG and the other is biased unless the range divides evenly.
|
|
146
|
+
const chosen = Array.from({ length: 6 }, () => words[randomInt(words.length)]);
|
|
147
|
+
return `${chosen.join('-')}-${String(randomInt(10, 100))}`;
|
|
148
|
+
}
|
|
118
149
|
async function main() {
|
|
119
150
|
const parsed = parseArgs(process.argv.slice(2));
|
|
120
151
|
if (typeof parsed === 'string')
|
|
@@ -127,11 +158,17 @@ async function main() {
|
|
|
127
158
|
'issue the first token, and the API will refuse to start without it either.',
|
|
128
159
|
]);
|
|
129
160
|
}
|
|
161
|
+
// Generated, never taken from an argument: `init` runs in a terminal, and an
|
|
162
|
+
// argument ends up in the shell history and in `ps` on a shared machine. Six
|
|
163
|
+
// words from the CSPRNG rather than a random string, because this is typed
|
|
164
|
+
// by a person into a sign-in form at least once.
|
|
165
|
+
const password = generatePassword();
|
|
166
|
+
const passwordHash = await hashPassword(password);
|
|
130
167
|
const pool = createPool({ connectionString: config.pgUrl, max: 2 });
|
|
131
168
|
const vector = vectorName(config.embeddingModel, config.embeddingDim);
|
|
132
169
|
const collection = collectionName(parsed.slug);
|
|
133
170
|
try {
|
|
134
|
-
const ids = await initialize(pool, parsed, {
|
|
171
|
+
const ids = await initialize(pool, { ...parsed, passwordHash }, {
|
|
135
172
|
endpoint: config.embeddingEndpoint,
|
|
136
173
|
model: config.embeddingModel,
|
|
137
174
|
dimensions: config.embeddingDim,
|
|
@@ -173,6 +210,14 @@ async function main() {
|
|
|
173
210
|
console.log('');
|
|
174
211
|
console.log(` export NACRE_TOKEN=${token}`);
|
|
175
212
|
console.log('');
|
|
213
|
+
console.log('And a password for signing in, which is not printed again:');
|
|
214
|
+
console.log('');
|
|
215
|
+
console.log(` ${password}`);
|
|
216
|
+
console.log('');
|
|
217
|
+
console.log(` curl -X POST ${config.canonicalUrl}/v1/auth/login \\`);
|
|
218
|
+
console.log(` -H 'Content-Type: application/json' \\`);
|
|
219
|
+
console.log(` -d '{"email":"${parsed.email}","password":"…"}'`);
|
|
220
|
+
console.log('');
|
|
176
221
|
console.log('Create a layer with it, then follow docs/quickstart.md:');
|
|
177
222
|
console.log('');
|
|
178
223
|
console.log(` curl -X POST ${config.canonicalUrl}/v1/layers \\`);
|
package/dist/init.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"init.js","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,WAAW,EACX,UAAU,EACV,UAAU,EACV,UAAU,EACV,WAAW,GACZ,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAE9B,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"init.js","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,WAAW,EACX,UAAU,EACV,YAAY,EACZ,UAAU,EACV,UAAU,EACV,WAAW,GACZ,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAE9B,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAwCxC,MAAM,UAAU,SAAS,CAAC,IAAuB;IAC/C,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAA;IACxC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACxC,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;QACnB,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAA;QACzB,IAAI,GAAG,KAAK,SAAS,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACtE,OAAO,+FAA+F,CAAA;QACxG,CAAC;QACD,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,CAAA;IACjC,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;IAC9B,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;IACjC,IAAI,IAAI,KAAK,SAAS,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QAC9C,OAAO,+FAA+F,CAAA;IACxG,CAAC;IACD,wEAAwE;IACxE,qCAAqC;IACrC,IAAI,CAAC,mCAAmC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACpD,OAAO,4GAA4G,IAAI,EAAE,CAAA;IAC3H,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,0CAA0C,KAAK,EAAE,CAAA;IAElF,OAAO;QACL,IAAI;QACJ,KAAK;QACL,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,IAAI;QAChC,SAAS,EAAE,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,IAAI,SAAS;KAChD,CAAA;AACH,CAAC;AAED,MAAM,GAAG,GAAG,CAAC,GAAW,EAAE,QAAiC,EAAE,EAAQ,EAAE;IACrE,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,EAAE,GAAG,KAAK,EAAE,CAAC,CAAC,CAAA;AAChD,CAAC,CAAA;AAUD;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,IAAU,EACV,OAAgB,EAChB,QAAiE,EACjE,UAAkB,EAClB,MAA8D,GAAG,EAAE,CAAC,SAAS;IAE7E,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAA;IACnC,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;QAE3B,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,KAAK,CACvC;;;;;;;;eAQS,EACT,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,UAAU,CAAC,CACzC,CAAA;QACD,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;QACnB,IAAI,GAAG,KAAK,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,yCAAyC,CAAC,CAAA;QACjF,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,8BAA8B,CAAC,CAAC,CAAC,sBAAsB,EAAE;YACzE,MAAM,EAAE,GAAG,CAAC,EAAE;YACd,IAAI,EAAE,OAAO,CAAC,IAAI;SACnB,CAAC,CAAA;QAEF,2EAA2E;QAC3E,2EAA2E;QAC3E,sEAAsE;QACtE,MAAM,MAAM,CAAC,KAAK,CAAC,iCAAiC,EAAE,CAAC,iBAAiB,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAA;QAElF,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,MAAM,CAAC,KAAK;QACxC,yEAAyE;QACzE,+DAA+D;QAC/D,wEAAwE;QACxE;;;;oBAIc,EACd,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,YAAY,IAAI,IAAI,CAAC,CACtD,CAAA;QACD,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAA;QAC3B,IAAI,MAAM,KAAK,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAA;QAC5E,GAAG,CAAC,kBAAkB,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAA;QAElE,2EAA2E;QAC3E,mEAAmE;QACnE,wDAAwD;QACxD,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,MAAM,MAAM,CAAC,KAAK,CAC5C;;;;;;;;;eASS,EACT,CAAC,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,CACzD,CAAA;QACD,MAAM,UAAU,GAAG,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAA;QACnC,IAAI,UAAU,KAAK,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,+CAA+C,CAAC,CAAA;QAC9F,GAAG,CAAC,0BAA0B,EAAE,EAAE,WAAW,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAA;QAEnF,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,MAAM,MAAM,CAAC,KAAK,CAC7C;;;;;;;;;;;eAWS,EACT,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,SAAS,CAAC,CAC/C,CAAA;QACD,MAAM,WAAW,GAAG,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,CAAA;QACrC,IAAI,WAAW,KAAK,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAA;QACtF,GAAG,CAAC,iBAAiB,EAAE,EAAE,YAAY,EAAE,WAAW,EAAE,IAAI,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,CAAA;QAE9E,MAAM,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAA;QAC5B,OAAO,EAAE,KAAK,EAAE,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,OAAO,EAAE,CAAA;IACtE,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;QAC9C,MAAM,KAAK,CAAA;IACb,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,OAAO,EAAE,CAAA;IAClB,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,gBAAgB;IACvB,MAAM,KAAK,GAAG;QACZ,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ;QAChF,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS;QAC5E,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM;QACxE,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO;QAC5E,UAAU,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ;QAC7E,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO;QAC1E,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ;QAC7E,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ;KAC7B,CAAA;IAEV,6EAA6E;IAC7E,kEAAkE;IAClE,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;IAC9E,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC,SAAS,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC,EAAE,CAAA;AAC5D,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAA;IAC/C,IAAI,OAAO,MAAM,KAAK,QAAQ;QAAE,MAAM,IAAI,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC,CAAA;IAE/D,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;IAC3B,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAA;IAC3C,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC;QAC/C,MAAM,IAAI,WAAW,CAAC;YACpB,4EAA4E;gBAC1E,4EAA4E;SAC/E,CAAC,CAAA;IACJ,CAAC;IAED,6EAA6E;IAC7E,6EAA6E;IAC7E,2EAA2E;IAC3E,iDAAiD;IACjD,MAAM,QAAQ,GAAG,gBAAgB,EAAE,CAAA;IACnC,MAAM,YAAY,GAAG,MAAM,YAAY,CAAC,QAAQ,CAAC,CAAA;IAEjD,MAAM,IAAI,GAAG,UAAU,CAAC,EAAE,gBAAgB,EAAE,MAAM,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAA;IACnE,MAAM,MAAM,GAAG,UAAU,CAAC,MAAM,CAAC,cAAc,EAAE,MAAM,CAAC,YAAY,CAAC,CAAA;IACrE,MAAM,UAAU,GAAG,cAAc,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;IAE9C,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,UAAU,CAC1B,IAAI,EACJ,EAAE,GAAG,MAAM,EAAE,YAAY,EAAE,EAC3B;YACE,QAAQ,EAAE,MAAM,CAAC,iBAAiB;YAClC,KAAK,EAAE,MAAM,CAAC,cAAc;YAC5B,UAAU,EAAE,MAAM,CAAC,YAAY;SAChC,EACD,UAAU,EACV,GAAG,CACJ,CAAA;QAED,0EAA0E;QAC1E,2EAA2E;QAC3E,qCAAqC;QACrC,EAAE;QACF,2EAA2E;QAC3E,2EAA2E;QAC3E,mEAAmE;QACnE,MAAM,OAAO,GAAG,IAAI,WAAW,CAC7B,MAAM,CAAC,YAAY,KAAK,SAAS;YAC/B,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,SAAS,EAAE;YAC3B,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,YAAY,EAAE,CAC3D,CAAA;QACD,MAAM,OAAO,CAAC,gBAAgB,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,YAAY,CAAC,CAAA;QACxE,GAAG,CAAC,kBAAkB,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,CAAC,YAAY,EAAE,CAAC,CAAA;QAEhF,yEAAyE;QACzE,yEAAyE;QACzE,wEAAwE;QACxE,sCAAsC;QACtC,MAAM,KAAK,GAAG,MAAM,IAAI,OAAO,CAAC;YAC9B,GAAG,EAAE,GAAG,CAAC,KAAK;YACd,cAAc,EAAE,MAAM;YACtB,IAAI,EAAE,WAAW;SAClB,CAAC;aACC,kBAAkB,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC;aACpC,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC;aACtB,SAAS,CAAC,MAAM,CAAC,SAAS,CAAC;aAC3B,WAAW,CAAC,MAAM,CAAC,WAAW,CAAC;aAC/B,iBAAiB,CAAC,IAAI,CAAC;aACvB,IAAI,CAAC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAA;QAEzC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACf,OAAO,CAAC,GAAG,CAAC,gBAAgB,MAAM,CAAC,IAAI,YAAY,CAAC,CAAA;QACpD,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACf,OAAO,CAAC,GAAG,CAAC,mBAAmB,GAAG,CAAC,WAAW,EAAE,CAAC,CAAA;QACjD,OAAO,CAAC,GAAG,CAAC,mBAAmB,MAAM,CAAC,KAAK,EAAE,CAAC,CAAA;QAC9C,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACf,OAAO,CAAC,GAAG,CAAC,6CAA6C,CAAC,CAAA;QAC1D,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACf,OAAO,CAAC,GAAG,CAAC,wBAAwB,KAAK,EAAE,CAAC,CAAA;QAC5C,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACf,OAAO,CAAC,GAAG,CAAC,4DAA4D,CAAC,CAAA;QACzE,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACf,OAAO,CAAC,GAAG,CAAC,KAAK,QAAQ,EAAE,CAAC,CAAA;QAC5B,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACf,OAAO,CAAC,GAAG,CAAC,kBAAkB,MAAM,CAAC,YAAY,mBAAmB,CAAC,CAAA;QACrE,OAAO,CAAC,GAAG,CAAC,4CAA4C,CAAC,CAAA;QACzD,OAAO,CAAC,GAAG,CAAC,qBAAqB,MAAM,CAAC,KAAK,oBAAoB,CAAC,CAAA;QAClE,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACf,OAAO,CAAC,GAAG,CAAC,yDAAyD,CAAC,CAAA;QACtE,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACf,OAAO,CAAC,GAAG,CAAC,kBAAkB,MAAM,CAAC,YAAY,eAAe,CAAC,CAAA;QACjE,OAAO,CAAC,GAAG,CAAC,oFAAoF,CAAC,CAAA;QACjG,OAAO,CAAC,GAAG,CACT,4BAA4B,GAAG,CAAC,WAAW,yCAAyC,CACrF,CAAA;QACD,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IACjB,CAAC;YAAS,CAAC;QACT,MAAM,IAAI,CAAC,GAAG,EAAE,CAAA;IAClB,CAAC;AACH,CAAC;AAED,6EAA6E;AAC7E,yEAAyE;AACzE,8EAA8E;AAC9E,mCAAmC;AACnC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,SAAS,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC7F,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;QAC9B,IAAI,KAAK,YAAY,WAAW,EAAE,CAAC;YACjC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;YAC5B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACjB,CAAC;QACD,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAA;QAC3E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC,CAAC,CAAA;AACJ,CAAC"}
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Redis } from '@nacre.work/core';
|
|
2
|
+
/**
|
|
3
|
+
* Rate limiting, per organization.
|
|
4
|
+
*
|
|
5
|
+
* **Per organization and not per token**, which is the whole point: a limit
|
|
6
|
+
* counted per credential is bypassed by minting another credential, and this
|
|
7
|
+
* product hands out service account keys through an endpoint.
|
|
8
|
+
*
|
|
9
|
+
* A fixed window rather than a sliding one. The cost is a burst at the
|
|
10
|
+
* boundary — a caller can spend a full window at 11:59:59 and another at
|
|
11
|
+
* 12:00:00 — and the benefit is that the whole thing is one `INCR` against a
|
|
12
|
+
* key whose name contains the window, so there is no set to trim and nothing to
|
|
13
|
+
* clean up. Stated rather than hidden because "60 per minute" is not quite what
|
|
14
|
+
* the boundary delivers.
|
|
15
|
+
*
|
|
16
|
+
* ## Failing open, deliberately
|
|
17
|
+
*
|
|
18
|
+
* If Redis is unreachable the request is **allowed**. That is the opposite of
|
|
19
|
+
* invariant I3, and the difference is the point: I3 is about permissions, where
|
|
20
|
+
* an unanswerable question must deny. A rate limit is availability protection,
|
|
21
|
+
* and failing closed would turn a Redis restart into a total outage of a
|
|
22
|
+
* product whose selling point is that it runs in your own network. The
|
|
23
|
+
* degradation is logged, and `nacre_rate_limit_unavailable_total` is what an
|
|
24
|
+
* operator alerts on.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* What a limit is counted against.
|
|
28
|
+
*
|
|
29
|
+
* `search` and `ingest` are counted per organization — a per-token limit is
|
|
30
|
+
* bypassed by issuing another key, and this API hands out keys. Login runs
|
|
31
|
+
* before there is an organization, so it is counted twice, on two different
|
|
32
|
+
* things, because either one alone leaves a hole:
|
|
33
|
+
*
|
|
34
|
+
* - `login` counts per email address, and defends one account against being
|
|
35
|
+
* guessed at. On its own it is nearly useless against the attack people
|
|
36
|
+
* actually run: one password sprayed across ten thousand addresses never
|
|
37
|
+
* repeats a key and never meets the limit.
|
|
38
|
+
* - `login_source` counts per client, and is what bounds that. On its own it
|
|
39
|
+
* over-trusts topology — everyone behind one NAT shares a bucket — which is
|
|
40
|
+
* why it is the looser of the two and why the address limit stays.
|
|
41
|
+
*
|
|
42
|
+
* Failing open matters more on both rather than less: a cache outage that
|
|
43
|
+
* locked everybody out of signing in would be the outage.
|
|
44
|
+
*/
|
|
45
|
+
export type Resource = 'search' | 'ingest' | 'login' | 'login_source';
|
|
46
|
+
export interface LimitDecision {
|
|
47
|
+
readonly allowed: boolean;
|
|
48
|
+
/** RFC 9331 `RateLimit-Limit`. */
|
|
49
|
+
readonly limit: number;
|
|
50
|
+
/** RFC 9331 `RateLimit-Remaining`, never negative. */
|
|
51
|
+
readonly remaining: number;
|
|
52
|
+
/** Seconds until the window resets — `RateLimit-Reset` and `Retry-After`. */
|
|
53
|
+
readonly reset: number;
|
|
54
|
+
/** True when the check could not run and the request was let through. */
|
|
55
|
+
readonly degraded: boolean;
|
|
56
|
+
}
|
|
57
|
+
export interface LimitPolicy {
|
|
58
|
+
/** Requests allowed per window. */
|
|
59
|
+
readonly limit: number;
|
|
60
|
+
/** Window length in seconds. */
|
|
61
|
+
readonly windowSeconds: number;
|
|
62
|
+
}
|
|
63
|
+
export interface RateLimiterOptions {
|
|
64
|
+
readonly redis: Redis;
|
|
65
|
+
readonly policies: Readonly<Record<Resource, LimitPolicy>>;
|
|
66
|
+
readonly onDegraded?: (resource: Resource, error: unknown) => void;
|
|
67
|
+
}
|
|
68
|
+
export declare class RateLimiter {
|
|
69
|
+
private readonly options;
|
|
70
|
+
constructor(options: RateLimiterOptions);
|
|
71
|
+
check(orgId: string, resource: Resource): Promise<LimitDecision>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* RFC 9331 headers, plus `Retry-After` when the answer is 429.
|
|
75
|
+
*
|
|
76
|
+
* `RateLimit-Policy` names the window so a client can tell 60/minute from
|
|
77
|
+
* 60/hour without guessing from `Reset`.
|
|
78
|
+
*/
|
|
79
|
+
export declare function limitHeaders(decision: LimitDecision, policy: LimitPolicy, resource: Resource): Record<string, string>;
|
|
80
|
+
//# sourceMappingURL=limits.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"limits.d.ts","sourceRoot":"","sources":["../src/limits.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAA;AAE7C;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,cAAc,CAAA;AAErE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,kCAAkC;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,sDAAsD;IACtD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;CAC3B;AAED,MAAM,WAAW,WAAW;IAC1B,mCAAmC;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,gCAAgC;IAChC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;CAC/B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAA;IAC1D,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;CACnE;AAED,qBAAa,WAAW;IACV,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,kBAAkB;IAElD,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,aAAa,CAAC;CA4CvE;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,aAAa,EACvB,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,QAAQ,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASxB"}
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export class RateLimiter {
|
|
2
|
+
options;
|
|
3
|
+
constructor(options) {
|
|
4
|
+
this.options = options;
|
|
5
|
+
}
|
|
6
|
+
async check(orgId, resource) {
|
|
7
|
+
const policy = this.options.policies[resource];
|
|
8
|
+
const windowMs = policy.windowSeconds * 1000;
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
const windowStart = Math.floor(now / windowMs) * windowMs;
|
|
11
|
+
const reset = Math.ceil((windowStart + windowMs - now) / 1000);
|
|
12
|
+
// The window is in the key, so a stale key belongs to a window nobody will
|
|
13
|
+
// ask about again and expiry is the only cleanup there is.
|
|
14
|
+
const key = `nacre:rl:${resource}:${orgId}:${windowStart}`;
|
|
15
|
+
try {
|
|
16
|
+
// INCR and PEXPIRE together: one round trip, and the TTL is refreshed on
|
|
17
|
+
// every hit rather than only on the first. Setting it once would leave
|
|
18
|
+
// the key immortal if the process died in between, and refreshing a
|
|
19
|
+
// window-scoped key changes nothing about when it actually expires.
|
|
20
|
+
const [count] = await this.options.redis.pipeline(['INCR', key], ['PEXPIRE', key, String(windowMs + 1000)]);
|
|
21
|
+
const used = typeof count === 'number' ? count : Number(count ?? 0);
|
|
22
|
+
return {
|
|
23
|
+
allowed: used <= policy.limit,
|
|
24
|
+
limit: policy.limit,
|
|
25
|
+
remaining: Math.max(0, policy.limit - used),
|
|
26
|
+
reset,
|
|
27
|
+
degraded: false,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
this.options.onDegraded?.(resource, error);
|
|
32
|
+
return {
|
|
33
|
+
allowed: true,
|
|
34
|
+
limit: policy.limit,
|
|
35
|
+
// Reporting the full allowance would claim a budget nothing is
|
|
36
|
+
// counting. Reporting the truth — the check did not run — is what the
|
|
37
|
+
// `degraded` flag is for, and the headers say the limit rather than a
|
|
38
|
+
// remaining figure that would be fiction.
|
|
39
|
+
remaining: policy.limit,
|
|
40
|
+
reset,
|
|
41
|
+
degraded: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* RFC 9331 headers, plus `Retry-After` when the answer is 429.
|
|
48
|
+
*
|
|
49
|
+
* `RateLimit-Policy` names the window so a client can tell 60/minute from
|
|
50
|
+
* 60/hour without guessing from `Reset`.
|
|
51
|
+
*/
|
|
52
|
+
export function limitHeaders(decision, policy, resource) {
|
|
53
|
+
const headers = {
|
|
54
|
+
'ratelimit-limit': String(decision.limit),
|
|
55
|
+
'ratelimit-remaining': String(decision.remaining),
|
|
56
|
+
'ratelimit-reset': String(decision.reset),
|
|
57
|
+
'ratelimit-policy': `${resource};q=${policy.limit};w=${policy.windowSeconds}`,
|
|
58
|
+
};
|
|
59
|
+
if (!decision.allowed)
|
|
60
|
+
headers['retry-after'] = String(decision.reset);
|
|
61
|
+
return headers;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=limits.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"limits.js","sourceRoot":"","sources":["../src/limits.ts"],"names":[],"mappings":"AAyEA,MAAM,OAAO,WAAW;IACO;IAA7B,YAA6B,OAA2B;QAA3B,YAAO,GAAP,OAAO,CAAoB;IAAG,CAAC;IAE5D,KAAK,CAAC,KAAK,CAAC,KAAa,EAAE,QAAkB;QAC3C,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAA;QAC9C,MAAM,QAAQ,GAAG,MAAM,CAAC,aAAa,GAAG,IAAI,CAAA;QAC5C,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;QACtB,MAAM,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,QAAQ,CAAC,GAAG,QAAQ,CAAA;QACzD,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,WAAW,GAAG,QAAQ,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC,CAAA;QAE9D,2EAA2E;QAC3E,2DAA2D;QAC3D,MAAM,GAAG,GAAG,YAAY,QAAQ,IAAI,KAAK,IAAI,WAAW,EAAE,CAAA;QAE1D,IAAI,CAAC;YACH,yEAAyE;YACzE,uEAAuE;YACvE,oEAAoE;YACpE,oEAAoE;YACpE,MAAM,CAAC,KAAK,CAAC,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAC/C,CAAC,MAAM,EAAE,GAAG,CAAC,EACb,CAAC,SAAS,EAAE,GAAG,EAAE,MAAM,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC,CAC1C,CAAA;YAED,MAAM,IAAI,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC,CAAC,CAAA;YACnE,OAAO;gBACL,OAAO,EAAE,IAAI,IAAI,MAAM,CAAC,KAAK;gBAC7B,KAAK,EAAE,MAAM,CAAC,KAAK;gBACnB,SAAS,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK,GAAG,IAAI,CAAC;gBAC3C,KAAK;gBACL,QAAQ,EAAE,KAAK;aAChB,CAAA;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;YAC1C,OAAO;gBACL,OAAO,EAAE,IAAI;gBACb,KAAK,EAAE,MAAM,CAAC,KAAK;gBACnB,+DAA+D;gBAC/D,sEAAsE;gBACtE,sEAAsE;gBACtE,0CAA0C;gBAC1C,SAAS,EAAE,MAAM,CAAC,KAAK;gBACvB,KAAK;gBACL,QAAQ,EAAE,IAAI;aACf,CAAA;QACH,CAAC;IACH,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAC1B,QAAuB,EACvB,MAAmB,EACnB,QAAkB;IAElB,MAAM,OAAO,GAA2B;QACtC,iBAAiB,EAAE,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QACzC,qBAAqB,EAAE,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC;QACjD,iBAAiB,EAAE,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QACzC,kBAAkB,EAAE,GAAG,QAAQ,MAAM,MAAM,CAAC,KAAK,MAAM,MAAM,CAAC,aAAa,EAAE;KAC9E,CAAA;IACD,IAAI,CAAC,QAAQ,CAAC,OAAO;QAAE,OAAO,CAAC,aAAa,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;IACtE,OAAO,OAAO,CAAA;AAChB,CAAC"}
|
package/dist/login.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Pool } from 'pg';
|
|
2
|
+
export interface LoginRequest {
|
|
3
|
+
readonly email: string;
|
|
4
|
+
readonly password: string;
|
|
5
|
+
/** Optional; see the note on invariant I1 above. */
|
|
6
|
+
readonly organization?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Tokens {
|
|
9
|
+
readonly accessToken: string;
|
|
10
|
+
readonly refreshToken: string;
|
|
11
|
+
readonly expiresIn: number;
|
|
12
|
+
/**
|
|
13
|
+
* Who this was issued to, so the caller can audit it.
|
|
14
|
+
*
|
|
15
|
+
* The audit log is per-organization, and on a successful sign-in there is one
|
|
16
|
+
* to write to. On a failure there is not — an address that matches no user
|
|
17
|
+
* belongs to no tenant, and inventing an owner for that row would put one
|
|
18
|
+
* organization's failed attempts in another's access log. Failures are logged
|
|
19
|
+
* by the process instead; see the handler.
|
|
20
|
+
*/
|
|
21
|
+
readonly orgId: string;
|
|
22
|
+
readonly userId: string;
|
|
23
|
+
}
|
|
24
|
+
export interface LoginDeps {
|
|
25
|
+
readonly pool: Pool;
|
|
26
|
+
readonly key: Uint8Array;
|
|
27
|
+
readonly issuer: string;
|
|
28
|
+
readonly audience: string;
|
|
29
|
+
readonly accessTokenTtl: number;
|
|
30
|
+
readonly refreshTokenTtl: number;
|
|
31
|
+
readonly role?: string;
|
|
32
|
+
/** Injected so tests are not asked to wait for wall-clock expiry. */
|
|
33
|
+
readonly now?: () => Date;
|
|
34
|
+
}
|
|
35
|
+
export declare class Login {
|
|
36
|
+
private readonly deps;
|
|
37
|
+
constructor(deps: LoginDeps);
|
|
38
|
+
private get scope();
|
|
39
|
+
private get clock();
|
|
40
|
+
/**
|
|
41
|
+
* Sign in, or refuse.
|
|
42
|
+
*
|
|
43
|
+
* `undefined` for every reason it can fail. The caller turns it into one
|
|
44
|
+
* `401` with one message.
|
|
45
|
+
*/
|
|
46
|
+
login(request: LoginRequest): Promise<Tokens | undefined>;
|
|
47
|
+
/**
|
|
48
|
+
* Find the one user this login is about, without saying how many nearly were.
|
|
49
|
+
*
|
|
50
|
+
* `organizations` carries no row-level security — an organization is not
|
|
51
|
+
* inside one — so the slug resolves on an ordinary connection, and the user
|
|
52
|
+
* is then read through `withOrg` like everything else. That is why the login
|
|
53
|
+
* path needs no cross-tenant read and no escape hatch: it is two scoped
|
|
54
|
+
* queries rather than one unscoped one.
|
|
55
|
+
*/
|
|
56
|
+
private find;
|
|
57
|
+
private orgIdForSlug;
|
|
58
|
+
private allOrgIds;
|
|
59
|
+
private userIn;
|
|
60
|
+
/**
|
|
61
|
+
* Exchange a refresh token for a new pair, or refuse.
|
|
62
|
+
*
|
|
63
|
+
* Rotation on every use. A token presented after it has been used is not
|
|
64
|
+
* tolerated as a client retry: the legitimate holder already exchanged it, so
|
|
65
|
+
* a second presentation means two parties hold the same token and one of them
|
|
66
|
+
* took it. Which one is unknowable from here, so the whole family is revoked
|
|
67
|
+
* and both sign in again.
|
|
68
|
+
*/
|
|
69
|
+
refresh(token: string): Promise<Tokens | undefined>;
|
|
70
|
+
/** Sign out. Ends the family, not just this token. */
|
|
71
|
+
logout(token: string): Promise<boolean>;
|
|
72
|
+
/**
|
|
73
|
+
* End every token descended from one login.
|
|
74
|
+
*
|
|
75
|
+
* Takes the same advisory lock the rotation path takes, and that is what
|
|
76
|
+
* makes it complete rather than approximately complete. Without it the two
|
|
77
|
+
* interleave badly: a statement's snapshot is fixed when the statement
|
|
78
|
+
* starts, so a revoke that begins before a rotation commits will not see the
|
|
79
|
+
* row that rotation inserts — and the token the racing party just obtained
|
|
80
|
+
* survives the revocation that was supposed to catch it. Serialized on the
|
|
81
|
+
* family, the revoke either runs first (and the rotation then finds the
|
|
82
|
+
* family revoked) or runs second (and sees the new row).
|
|
83
|
+
*/
|
|
84
|
+
private revokeFamily;
|
|
85
|
+
/**
|
|
86
|
+
* Mint an access token and the refresh token that succeeds it.
|
|
87
|
+
*
|
|
88
|
+
* `family` carries across a rotation so the whole chain from one login can be
|
|
89
|
+
* revoked together; a fresh login starts a new one, so signing out of one
|
|
90
|
+
* device does not sign out of the others.
|
|
91
|
+
*/
|
|
92
|
+
private issue;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=login.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../src/login.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,IAAI,EAAc,MAAM,IAAI,CAAA;AAyC1C,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAC/B;AAED,MAAM,WAAW,MAAM;IACrB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;IACnB,QAAQ,CAAC,GAAG,EAAE,UAAU,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAChC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,qEAAqE;IACrE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CAC1B;AAeD,qBAAa,KAAK;IACJ,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,SAAS;IAE5C,OAAO,KAAK,KAAK,GAEhB;IAED,OAAO,KAAK,KAAK,GAEhB;IAED;;;;;OAKG;IACG,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAiC/D;;;;;;;;OAQG;YACW,IAAI;YAuBJ,YAAY;YAaZ,SAAS;YAYT,MAAM;IAgBpB;;;;;;;;OAQG;IACG,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IA+FzD,sDAAsD;IAChD,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAkB7C;;;;;;;;;;;OAWG;YACW,YAAY;IAe1B;;;;;;OAMG;YACW,KAAK;CA+CpB"}
|