@loams/client 0.1.0 → 0.1.2
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/README.md +104 -4
- package/dist/app.d.ts +149 -0
- package/dist/app.js +317 -0
- package/dist/builders/filter.d.ts +96 -0
- package/dist/builders/filter.js +190 -0
- package/dist/builders/schema.d.ts +50 -0
- package/dist/builders/schema.js +92 -0
- package/dist/builders/search.d.ts +159 -0
- package/dist/builders/search.js +307 -0
- package/dist/gen/facade.d.ts +267 -2
- package/dist/gen/facade.js +194 -0
- package/dist/handle.d.ts +67 -0
- package/dist/handle.js +107 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +13 -3
- package/dist/loams.d.ts +78 -3
- package/dist/loams.js +84 -6
- package/dist/node.js +1 -1
- package/dist/runtime/batch.d.ts +29 -0
- package/dist/runtime/batch.js +95 -0
- package/dist/runtime/call.d.ts +63 -9
- package/dist/runtime/call.js +243 -41
- package/dist/runtime/consistency.d.ts +65 -11
- package/dist/runtime/consistency.js +212 -46
- package/dist/runtime/errors.d.ts +29 -2
- package/dist/runtime/errors.js +26 -0
- package/dist/runtime/machine-key.d.ts +33 -0
- package/dist/runtime/machine-key.js +174 -0
- package/dist/runtime/pagination.js +8 -2
- package/dist/runtime/subscribe.d.ts +37 -0
- package/dist/runtime/subscribe.js +128 -0
- package/dist/runtime/token-source.d.ts +1 -1
- package/dist/runtime/token-source.js +1 -1
- package/dist/runtime/transports.d.ts +22 -1
- package/dist/runtime/transports.js +28 -2
- package/dist/vendor/proto/collection-v1.d.ts +3 -0
- package/dist/vendor/proto/collection-v1.js +7 -0
- package/dist/vendor/proto/gen/loams/auth/v1/auth_pb.d.ts +7 -2
- package/dist/vendor/proto/gen/loams/auth/v1/auth_pb.js +5 -1
- package/dist/vendor/proto/gen/loams/collection/v1/collection_pb.d.ts +1610 -0
- package/dist/vendor/proto/gen/loams/collection/v1/collection_pb.js +399 -0
- package/dist/vendor/proto/gen/loams/collection/v1/document_pb.d.ts +1054 -0
- package/dist/vendor/proto/gen/loams/collection/v1/document_pb.js +327 -0
- package/dist/vendor/proto/gen/loams/collection/v1/query_pb.d.ts +2101 -0
- package/dist/vendor/proto/gen/loams/collection/v1/query_pb.js +608 -0
- package/dist/vendor/proto/gen/loams/graph/v1/graph_pb.d.ts +2299 -0
- package/dist/vendor/proto/gen/loams/graph/v1/graph_pb.js +596 -0
- package/dist/vendor/proto/gen/loams/instance/v1/instance_pb.d.ts +23 -7
- package/dist/vendor/proto/gen/loams/instance/v1/instance_pb.js +20 -5
- package/dist/vendor/proto/gen/loams/operations/v1/operations_pb.d.ts +399 -0
- package/dist/vendor/proto/gen/loams/operations/v1/operations_pb.js +118 -0
- package/dist/vendor/proto/gen/loams/sql/v1/sql_pb.d.ts +182 -0
- package/dist/vendor/proto/gen/loams/sql/v1/sql_pb.js +72 -0
- package/dist/vendor/proto/gen/loams/stream/v1/stream_pb.d.ts +688 -0
- package/dist/vendor/proto/gen/loams/stream/v1/stream_pb.js +180 -0
- package/package.json +2 -2
|
@@ -1,68 +1,234 @@
|
|
|
1
|
-
// Consistency tokens (design §44 §7.4, D609; runtime contract R4
|
|
1
|
+
// Consistency tokens (design §44 §7.4, D609; runtime contract R4 as amended
|
|
2
|
+
// by SDKP §2.1).
|
|
2
3
|
//
|
|
3
|
-
// A write answers with a
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// off by default, and when a call opts in, every response's token is merged
|
|
7
|
-
// into the session and attached to later reads.
|
|
4
|
+
// A write answers with a token; a read accepts one, so a caller that just
|
|
5
|
+
// wrote can read its own write. The encoding is the server's
|
|
6
|
+
// (`crates/loams-collection/src/token.rs`):
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
8
|
+
// v1:s<stream>/p<partition>@<offset>,s<stream>/p<partition>@<offset>,…
|
|
9
|
+
//
|
|
10
|
+
// Each item is "the next offset after the write" for one `(stream, partition)`.
|
|
11
|
+
// Merging two tokens keeps, per pair, the larger offset: a read held to the
|
|
12
|
+
// merge waits for both writes. Every number is canonical decimal (no sign, no
|
|
13
|
+
// leading zeros), so a token has exactly one spelling, and a malformed token is
|
|
14
|
+
// `invalid_argument` here, before any network call.
|
|
15
|
+
//
|
|
16
|
+
// The session store is off by default. With `sessionConsistency: true` every
|
|
17
|
+
// token a response carries is merged into it, and every read whose request has
|
|
18
|
+
// a `consistency` field is sent `at_least = session` when the caller left the
|
|
19
|
+
// field unset.
|
|
20
|
+
//
|
|
21
|
+
// Offsets are `bigint`: an offset is a `u64` on the server, and a `number`
|
|
22
|
+
// silently rounds above 2^53, which would make a read wait for the wrong
|
|
23
|
+
// position.
|
|
18
24
|
import { Code } from '@connectrpc/connect';
|
|
25
|
+
import { InvalidArgumentError } from './errors.js';
|
|
19
26
|
/** The prefix every token carries (§44 §7.4). */
|
|
20
27
|
export const TOKEN_PREFIX = 'v1:';
|
|
21
|
-
/** Whether a string looks like a consistency token. */
|
|
28
|
+
/** Whether a string looks like a consistency token (the prefix only). */
|
|
22
29
|
export function isConsistencyToken(value) {
|
|
23
|
-
return value.startsWith(TOKEN_PREFIX)
|
|
30
|
+
return value.startsWith(TOKEN_PREFIX);
|
|
31
|
+
}
|
|
32
|
+
const U32_MAX = 0xffffffffn;
|
|
33
|
+
const U64_MAX = 0xffffffffffffffffn;
|
|
34
|
+
/** A decimal `u64` with no sign and no leading zeros (except `"0"`). */
|
|
35
|
+
function canonical(text, max) {
|
|
36
|
+
if (text === '' || !/^[0-9]+$/.test(text) || (text.length > 1 && text.startsWith('0'))) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const value = BigInt(text);
|
|
40
|
+
return value <= max ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
/** `s<stream>/p<partition>@<offset>`. */
|
|
43
|
+
function parseItem(item) {
|
|
44
|
+
const match = /^s([^/]*)\/p([^@]*)@(.*)$/.exec(item);
|
|
45
|
+
if (match === null) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const stream = canonical(match[1], U64_MAX);
|
|
49
|
+
const partition = canonical(match[2], U32_MAX);
|
|
50
|
+
const offset = canonical(match[3], U64_MAX);
|
|
51
|
+
if (stream === undefined || partition === undefined || offset === undefined) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return { stream, partition: Number(partition), offset };
|
|
55
|
+
}
|
|
56
|
+
function compare(a, b) {
|
|
57
|
+
if (a.stream !== b.stream) {
|
|
58
|
+
return a.stream < b.stream ? -1 : 1;
|
|
59
|
+
}
|
|
60
|
+
if (a.partition !== b.partition) {
|
|
61
|
+
return a.partition - b.partition;
|
|
62
|
+
}
|
|
63
|
+
if (a.offset !== b.offset) {
|
|
64
|
+
return a.offset < b.offset ? -1 : 1;
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
24
67
|
}
|
|
25
68
|
/**
|
|
26
|
-
* A
|
|
69
|
+
* A parsed consistency token: `parse`, `toString`, `merge`, `equals`.
|
|
27
70
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
71
|
+
* Immutable. `merge` returns a new token, normalised (sorted by stream and
|
|
72
|
+
* partition, one item per pair, the max offset kept), which is exactly what
|
|
73
|
+
* `ConsistencyToken::merge` on the server produces, so the two print the same.
|
|
74
|
+
*/
|
|
75
|
+
export class ConsistencyToken {
|
|
76
|
+
positions;
|
|
77
|
+
constructor(positions) {
|
|
78
|
+
this.positions = positions;
|
|
79
|
+
}
|
|
80
|
+
/** The token that names nothing: `v1:`. */
|
|
81
|
+
static empty() {
|
|
82
|
+
return new ConsistencyToken([]);
|
|
83
|
+
}
|
|
84
|
+
/** A token from its items, in the order given. */
|
|
85
|
+
static of(positions) {
|
|
86
|
+
return new ConsistencyToken([...positions]);
|
|
87
|
+
}
|
|
88
|
+
/** Parses the text form, or throws `InvalidArgumentError`. The items are
|
|
89
|
+
* kept in the order given, as the server's parser keeps them. */
|
|
90
|
+
static parse(text) {
|
|
91
|
+
const invalid = () => new InvalidArgumentError(`invalid consistency token ${JSON.stringify(text)}`, {
|
|
92
|
+
code: Code.InvalidArgument,
|
|
93
|
+
reason: 'invalid_consistency_token',
|
|
94
|
+
metadata: { field: 'consistency_token' },
|
|
95
|
+
});
|
|
96
|
+
if (!text.startsWith(TOKEN_PREFIX)) {
|
|
97
|
+
throw invalid();
|
|
98
|
+
}
|
|
99
|
+
const body = text.slice(TOKEN_PREFIX.length);
|
|
100
|
+
if (body === '') {
|
|
101
|
+
return ConsistencyToken.empty();
|
|
102
|
+
}
|
|
103
|
+
const positions = [];
|
|
104
|
+
for (const item of body.split(',')) {
|
|
105
|
+
const position = parseItem(item);
|
|
106
|
+
if (position === undefined) {
|
|
107
|
+
throw invalid();
|
|
108
|
+
}
|
|
109
|
+
positions.push(position);
|
|
110
|
+
}
|
|
111
|
+
return new ConsistencyToken(positions);
|
|
112
|
+
}
|
|
113
|
+
/** Whether `text` parses. */
|
|
114
|
+
static isValid(text) {
|
|
115
|
+
try {
|
|
116
|
+
ConsistencyToken.parse(text);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Sorted by `(stream, partition)`, one item per pair, the max offset kept. */
|
|
124
|
+
normalized() {
|
|
125
|
+
const sorted = [...this.positions].sort(compare);
|
|
126
|
+
const items = [];
|
|
127
|
+
for (const item of sorted) {
|
|
128
|
+
const last = items[items.length - 1];
|
|
129
|
+
if (last !== undefined && last.stream === item.stream && last.partition === item.partition) {
|
|
130
|
+
items[items.length - 1] = item;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
items.push(item);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return new ConsistencyToken(items);
|
|
137
|
+
}
|
|
138
|
+
/** Both tokens' offsets; a pair both name keeps the larger. Normalised. */
|
|
139
|
+
merge(other) {
|
|
140
|
+
const right = typeof other === 'string' ? ConsistencyToken.parse(other) : other;
|
|
141
|
+
return new ConsistencyToken([...this.positions, ...right.positions]).normalized();
|
|
142
|
+
}
|
|
143
|
+
/** The offset for `(stream, partition)`, the max if listed twice. */
|
|
144
|
+
offset(stream, partition) {
|
|
145
|
+
const s = BigInt(stream);
|
|
146
|
+
let found;
|
|
147
|
+
for (const item of this.positions) {
|
|
148
|
+
if (item.stream === s && item.partition === partition) {
|
|
149
|
+
found = found === undefined || item.offset > found ? item.offset : found;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return found;
|
|
153
|
+
}
|
|
154
|
+
/** Whether the two tokens name the same positions (after normalising). */
|
|
155
|
+
equals(other) {
|
|
156
|
+
const right = typeof other === 'string' ? ConsistencyToken.parse(other) : other;
|
|
157
|
+
return this.normalized().toString() === right.normalized().toString();
|
|
158
|
+
}
|
|
159
|
+
/** Whether the token names nothing. */
|
|
160
|
+
get isEmpty() {
|
|
161
|
+
return this.positions.length === 0;
|
|
162
|
+
}
|
|
163
|
+
/** The text form, `v1:` plus the items joined by `,`, in this token's order. */
|
|
164
|
+
toString() {
|
|
165
|
+
return (TOKEN_PREFIX +
|
|
166
|
+
this.positions
|
|
167
|
+
.map((item) => `s${item.stream}/p${item.partition}@${item.offset}`)
|
|
168
|
+
.join(','));
|
|
169
|
+
}
|
|
170
|
+
toJSON() {
|
|
171
|
+
return this.toString();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* A session's consistency tokens: every token a response carried, merged, kept
|
|
176
|
+
* **per proto package** (R4, SDKP-17).
|
|
177
|
+
*
|
|
178
|
+
* A Graph read refuses a token naming another package's stream, so a
|
|
179
|
+
* collection write's token must never become a graph read's `at_least`: the
|
|
180
|
+
* session holds one merged token per scope, and the call path passes the
|
|
181
|
+
* call's package as the scope. A caller using the store directly may leave the
|
|
182
|
+
* scope out, which is one shared scope.
|
|
183
|
+
*
|
|
184
|
+
* `record` folds a token in; `current` is the scope's merge, as text. A token
|
|
185
|
+
* that does not parse is counted in `rejected` and otherwise ignored: the write
|
|
186
|
+
* that answered with it has already happened, and turning a committed write
|
|
187
|
+
* into a thrown error invites a caller to retry it.
|
|
31
188
|
*/
|
|
32
189
|
export class ConsistencySession {
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
190
|
+
#tokens = new Map();
|
|
191
|
+
#rejected = 0;
|
|
192
|
+
/** The token to attach to the next read in `scope`, or undefined. */
|
|
193
|
+
current(scope = '') {
|
|
194
|
+
return this.#tokens.get(scope)?.toString();
|
|
195
|
+
}
|
|
196
|
+
/** The merged token of `scope`, parsed. */
|
|
197
|
+
token(scope = '') {
|
|
198
|
+
return this.#tokens.get(scope);
|
|
199
|
+
}
|
|
200
|
+
/** The scopes the session holds a token for. */
|
|
201
|
+
scopes() {
|
|
202
|
+
return [...this.#tokens.keys()];
|
|
203
|
+
}
|
|
204
|
+
/** Folds a token the server returned, in `scope`, in. Throws
|
|
205
|
+
* `InvalidArgumentError` on a malformed token, after counting it. */
|
|
206
|
+
record(token, scope = '') {
|
|
43
207
|
if (token === undefined || token === '') {
|
|
44
208
|
return;
|
|
45
209
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = typeof token === 'string' ? ConsistencyToken.parse(token) : token;
|
|
49
213
|
}
|
|
50
|
-
|
|
51
|
-
this.#
|
|
52
|
-
|
|
214
|
+
catch (error) {
|
|
215
|
+
this.#rejected += 1;
|
|
216
|
+
throw error;
|
|
53
217
|
}
|
|
54
|
-
this.#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
218
|
+
const held = this.#tokens.get(scope);
|
|
219
|
+
this.#tokens.set(scope, held === undefined ? parsed.normalized() : held.merge(parsed));
|
|
220
|
+
}
|
|
221
|
+
/** How many tokens the session could not parse. */
|
|
222
|
+
get rejected() {
|
|
223
|
+
return this.#rejected;
|
|
58
224
|
}
|
|
59
|
-
/**
|
|
225
|
+
/** @deprecated The old name of `rejected`, from before tokens merged. */
|
|
60
226
|
get conflicts() {
|
|
61
|
-
return this.#
|
|
227
|
+
return this.#rejected;
|
|
62
228
|
}
|
|
63
|
-
/** Forgets
|
|
229
|
+
/** Forgets every token, so the next read is not held to one. */
|
|
64
230
|
clear() {
|
|
65
|
-
this.#
|
|
66
|
-
this.#
|
|
231
|
+
this.#tokens.clear();
|
|
232
|
+
this.#rejected = 0;
|
|
67
233
|
}
|
|
68
234
|
}
|
package/dist/runtime/errors.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { Code, ConnectError } from '@connectrpc/connect';
|
|
2
2
|
import { type Reason } from '../gen/facade.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reasons the SDK raises itself, without a server answer (SDKP §2.1, R12 and
|
|
5
|
+
* R13). They are registered in `docs/api/reasons.md` as client-side, and are
|
|
6
|
+
* kept here as well so the SDK does not depend on when that registry lands.
|
|
7
|
+
*/
|
|
8
|
+
export declare const CLIENT_REASONS: readonly ["client_deadline_exceeded", "batch_partially_applied"];
|
|
9
|
+
/** A reason the SDK raises itself. */
|
|
10
|
+
export type ClientReason = (typeof CLIENT_REASONS)[number];
|
|
3
11
|
/** What every Loams failure carries. */
|
|
4
12
|
export declare class LoamsError extends Error {
|
|
5
13
|
/** The Connect code, the canonical classification. */
|
|
@@ -7,7 +15,7 @@ export declare class LoamsError extends Error {
|
|
|
7
15
|
/** The stable cause, when the server sent an `ErrorInfo`. Undefined means
|
|
8
16
|
* the failure came from below the API (a socket, a timeout, a CORS
|
|
9
17
|
* rejection), not from a Loams service. */
|
|
10
|
-
readonly reason: Reason | undefined;
|
|
18
|
+
readonly reason: Reason | ClientReason | undefined;
|
|
11
19
|
/** Structured context the server sent. Never secrets. */
|
|
12
20
|
readonly metadata: Readonly<Record<string, string>>;
|
|
13
21
|
/** A short next step in the caller's locale, when the server sent one. */
|
|
@@ -19,7 +27,7 @@ export declare class LoamsError extends Error {
|
|
|
19
27
|
readonly unknownReason: string | undefined;
|
|
20
28
|
constructor(message: string, init: {
|
|
21
29
|
code: Code;
|
|
22
|
-
reason?: Reason;
|
|
30
|
+
reason?: Reason | ClientReason;
|
|
23
31
|
unknownReason?: string;
|
|
24
32
|
metadata?: Record<string, string>;
|
|
25
33
|
hint?: string;
|
|
@@ -69,6 +77,25 @@ export declare class FeatureNotInVariantError extends UnimplementedError {
|
|
|
69
77
|
variant?: string;
|
|
70
78
|
});
|
|
71
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* A `documents.writeBatch` that stopped at a failed chunk (runtime contract
|
|
82
|
+
* R13). The chunks before it are applied and stay applied; `token` is their
|
|
83
|
+
* merged consistency token, so a caller can read what did land, and `cause` is
|
|
84
|
+
* the failed chunk's own error.
|
|
85
|
+
*/
|
|
86
|
+
export declare class BatchError extends LoamsError {
|
|
87
|
+
/** How many chunks were applied before the failure. */
|
|
88
|
+
readonly chunksApplied: number;
|
|
89
|
+
/** How many documents those chunks carried. */
|
|
90
|
+
readonly rowsApplied: number;
|
|
91
|
+
/** The merged token of the applied chunks, if any carried one. */
|
|
92
|
+
readonly token: string | undefined;
|
|
93
|
+
constructor(message: string, init: ConstructorParameters<typeof LoamsError>[1] & {
|
|
94
|
+
chunksApplied: number;
|
|
95
|
+
rowsApplied: number;
|
|
96
|
+
token?: string;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
72
99
|
/** A token the server rejected as expired: `unauthenticated` with reason
|
|
73
100
|
* `token_expired`. The runtime refreshes once and retries once (D608, R1). */
|
|
74
101
|
export declare class TokenExpiredError extends UnauthenticatedError {
|
package/dist/runtime/errors.js
CHANGED
|
@@ -13,6 +13,12 @@ import { REASONS } from '../gen/facade.js';
|
|
|
13
13
|
* union does not have is a server newer than this SDK, and is surfaced rather
|
|
14
14
|
* than dropped. */
|
|
15
15
|
const KNOWN_REASONS = new Set(REASONS);
|
|
16
|
+
/**
|
|
17
|
+
* Reasons the SDK raises itself, without a server answer (SDKP §2.1, R12 and
|
|
18
|
+
* R13). They are registered in `docs/api/reasons.md` as client-side, and are
|
|
19
|
+
* kept here as well so the SDK does not depend on when that registry lands.
|
|
20
|
+
*/
|
|
21
|
+
export const CLIENT_REASONS = ['client_deadline_exceeded', 'batch_partially_applied'];
|
|
16
22
|
/** What every Loams failure carries. */
|
|
17
23
|
export class LoamsError extends Error {
|
|
18
24
|
/** The Connect code, the canonical classification. */
|
|
@@ -84,6 +90,26 @@ export class FeatureNotInVariantError extends UnimplementedError {
|
|
|
84
90
|
this.variant = init.variant;
|
|
85
91
|
}
|
|
86
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* A `documents.writeBatch` that stopped at a failed chunk (runtime contract
|
|
95
|
+
* R13). The chunks before it are applied and stay applied; `token` is their
|
|
96
|
+
* merged consistency token, so a caller can read what did land, and `cause` is
|
|
97
|
+
* the failed chunk's own error.
|
|
98
|
+
*/
|
|
99
|
+
export class BatchError extends LoamsError {
|
|
100
|
+
/** How many chunks were applied before the failure. */
|
|
101
|
+
chunksApplied;
|
|
102
|
+
/** How many documents those chunks carried. */
|
|
103
|
+
rowsApplied;
|
|
104
|
+
/** The merged token of the applied chunks, if any carried one. */
|
|
105
|
+
token;
|
|
106
|
+
constructor(message, init) {
|
|
107
|
+
super(message, init);
|
|
108
|
+
this.chunksApplied = init.chunksApplied;
|
|
109
|
+
this.rowsApplied = init.rowsApplied;
|
|
110
|
+
this.token = init.token;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
87
113
|
/** A token the server rejected as expired: `unauthenticated` with reason
|
|
88
114
|
* `token_expired`. The runtime refreshes once and retries once (D608, R1). */
|
|
89
115
|
export class TokenExpiredError extends UnauthenticatedError {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { TokenSource } from './token-source.js';
|
|
2
|
+
/** How long an assertion is valid, in seconds. */
|
|
3
|
+
export declare const MACHINE_KEY_ASSERTION_TTL_S = 300;
|
|
4
|
+
/** How long before expiry a token is traded again, in seconds. */
|
|
5
|
+
export declare const MACHINE_KEY_REFRESH_EARLY_S = 60;
|
|
6
|
+
/** The RFC 7523 grant type. */
|
|
7
|
+
export declare const JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
8
|
+
/** A machine user's key file, as the identity provider hands it out. */
|
|
9
|
+
export interface MachineKeyFile {
|
|
10
|
+
type?: string;
|
|
11
|
+
keyId: string;
|
|
12
|
+
key: string;
|
|
13
|
+
userId: string;
|
|
14
|
+
}
|
|
15
|
+
export interface MachineKeyOptions {
|
|
16
|
+
/** The key file's JSON text, or the parsed file. */
|
|
17
|
+
keyFile: string | MachineKeyFile;
|
|
18
|
+
/** The identity provider's issuer URL (`https://auth.loams.dev`). */
|
|
19
|
+
issuer: string;
|
|
20
|
+
/** The Loams project's id at the identity provider: the audience Loams checks. */
|
|
21
|
+
project: string;
|
|
22
|
+
/** Injected for tests and for hosts with their own `fetch`. */
|
|
23
|
+
fetch?: typeof globalThis.fetch;
|
|
24
|
+
/** The clock, in milliseconds (tests). */
|
|
25
|
+
now?: () => number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* An agent's token source from its machine key (module header). The key is checked when the
|
|
29
|
+
* source is built; it is imported into WebCrypto on first use.
|
|
30
|
+
*/
|
|
31
|
+
export declare function machineKey(options: MachineKeyOptions): TokenSource & {
|
|
32
|
+
readonly userId: string;
|
|
33
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// An agent's token from its identity provider's private-key JWT key (design 76 §10, D1607;
|
|
2
|
+
// plan IA1, IA4 Task 2).
|
|
3
|
+
//
|
|
4
|
+
// An agent is a machine user at the identity provider (Zitadel). Its key file is the JSON the
|
|
5
|
+
// provider hands out once (`{"type": "serviceaccount", "keyId", "key", "userId"}`). The source
|
|
6
|
+
// signs an RFC 7523 assertion (RS256, `kid` = `keyId`, `iss` = `sub` = `userId`, `aud` = the
|
|
7
|
+
// issuer, valid for 5 minutes) with WebCrypto, trades it at `<issuer>/oauth/v2/token` for an
|
|
8
|
+
// access token whose audience is the Loams project
|
|
9
|
+
// (`urn:zitadel:iam:org:project:id:<project>:aud`), caches it, and trades again a minute
|
|
10
|
+
// before it expires or when the server answers `token_expired`. Concurrent callers share one
|
|
11
|
+
// trade. The private key never leaves the process. A sandboxed agent holds no key: agentd
|
|
12
|
+
// vends it a 15-minute token file instead (D1618).
|
|
13
|
+
/** How long an assertion is valid, in seconds. */
|
|
14
|
+
export const MACHINE_KEY_ASSERTION_TTL_S = 300;
|
|
15
|
+
/** How long before expiry a token is traded again, in seconds. */
|
|
16
|
+
export const MACHINE_KEY_REFRESH_EARLY_S = 60;
|
|
17
|
+
/** The RFC 7523 grant type. */
|
|
18
|
+
export const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
|
|
19
|
+
const b64url = (bytes) => {
|
|
20
|
+
let text = '';
|
|
21
|
+
for (const b of bytes) {
|
|
22
|
+
text += String.fromCharCode(b);
|
|
23
|
+
}
|
|
24
|
+
return btoa(text).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
|
|
25
|
+
};
|
|
26
|
+
const utf8 = (text) => Uint8Array.from(new TextEncoder().encode(text));
|
|
27
|
+
function derLength(n) {
|
|
28
|
+
if (n < 0x80) {
|
|
29
|
+
return Uint8Array.of(n);
|
|
30
|
+
}
|
|
31
|
+
const out = [];
|
|
32
|
+
for (let v = n; v > 0; v >>= 8) {
|
|
33
|
+
out.unshift(v & 0xff);
|
|
34
|
+
}
|
|
35
|
+
return Uint8Array.of(0x80 | out.length, ...out);
|
|
36
|
+
}
|
|
37
|
+
function der(tag, ...parts) {
|
|
38
|
+
const body = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
39
|
+
let at = 0;
|
|
40
|
+
for (const p of parts) {
|
|
41
|
+
body.set(p, at);
|
|
42
|
+
at += p.length;
|
|
43
|
+
}
|
|
44
|
+
const length = derLength(body.length);
|
|
45
|
+
const out = new Uint8Array(1 + length.length + body.length);
|
|
46
|
+
out[0] = tag;
|
|
47
|
+
out.set(length, 1);
|
|
48
|
+
out.set(body, 1 + length.length);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** PKCS #1 `RSAPrivateKey` wrapped as PKCS #8, which is what WebCrypto imports. */
|
|
52
|
+
function pkcs1ToPkcs8(pkcs1) {
|
|
53
|
+
const version = Uint8Array.of(0x02, 0x01, 0x00);
|
|
54
|
+
// rsaEncryption (1.2.840.113549.1.1.1) with NULL parameters.
|
|
55
|
+
const algorithm = Uint8Array.of(0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00);
|
|
56
|
+
return der(0x30, version, algorithm, der(0x04, pkcs1));
|
|
57
|
+
}
|
|
58
|
+
function pemToPkcs8(pem) {
|
|
59
|
+
const match = /-----BEGIN (RSA )?PRIVATE KEY-----([\s\S]+?)-----END (RSA )?PRIVATE KEY-----/u.exec(pem);
|
|
60
|
+
if (match === null) {
|
|
61
|
+
throw new Error('machineKey: the key is not a PEM private key');
|
|
62
|
+
}
|
|
63
|
+
const bytes = Uint8Array.from(atob((match[2] ?? '').replace(/\s+/gu, '')), (c) => c.charCodeAt(0));
|
|
64
|
+
return match[1] === undefined ? bytes : pkcs1ToPkcs8(bytes);
|
|
65
|
+
}
|
|
66
|
+
function parseKeyFile(keyFile) {
|
|
67
|
+
let file;
|
|
68
|
+
if (typeof keyFile === 'string') {
|
|
69
|
+
try {
|
|
70
|
+
file = JSON.parse(keyFile);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new Error('machineKey: not a machine key file');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
file = keyFile;
|
|
78
|
+
}
|
|
79
|
+
if (file.type !== undefined && file.type !== '' && file.type !== 'serviceaccount') {
|
|
80
|
+
throw new Error(`machineKey: a ${JSON.stringify(file.type)} key is not a machine user's key`);
|
|
81
|
+
}
|
|
82
|
+
if (!file.keyId || !file.userId || !file.key) {
|
|
83
|
+
throw new Error('machineKey: the key file has no keyId, key or userId');
|
|
84
|
+
}
|
|
85
|
+
return file;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* An agent's token source from its machine key (module header). The key is checked when the
|
|
89
|
+
* source is built; it is imported into WebCrypto on first use.
|
|
90
|
+
*/
|
|
91
|
+
export function machineKey(options) {
|
|
92
|
+
const file = parseKeyFile(options.keyFile);
|
|
93
|
+
const issuer = options.issuer.replace(/\/+$/u, '');
|
|
94
|
+
if (!/^https?:\/\/[^/]+/u.test(issuer)) {
|
|
95
|
+
throw new Error(`machineKey: the issuer ${JSON.stringify(issuer)} is not a URL`);
|
|
96
|
+
}
|
|
97
|
+
if (options.project === '') {
|
|
98
|
+
throw new Error('machineKey: the Loams project id (the audience) is empty');
|
|
99
|
+
}
|
|
100
|
+
const pkcs8 = pemToPkcs8(file.key);
|
|
101
|
+
const scope = `openid urn:zitadel:iam:org:project:id:${options.project}:aud`;
|
|
102
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
103
|
+
const now = options.now ?? Date.now;
|
|
104
|
+
let signingKey;
|
|
105
|
+
let cached;
|
|
106
|
+
let inFlight;
|
|
107
|
+
const assertion = async (issuedAtMs) => {
|
|
108
|
+
signingKey ??= globalThis.crypto.subtle.importKey('pkcs8', pkcs8, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
|
|
109
|
+
const iat = Math.floor(issuedAtMs / 1000);
|
|
110
|
+
const header = b64url(utf8(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid: file.keyId })));
|
|
111
|
+
const claims = b64url(utf8(JSON.stringify({
|
|
112
|
+
iss: file.userId,
|
|
113
|
+
sub: file.userId,
|
|
114
|
+
aud: issuer,
|
|
115
|
+
iat,
|
|
116
|
+
exp: iat + MACHINE_KEY_ASSERTION_TTL_S,
|
|
117
|
+
})));
|
|
118
|
+
const signing = `${header}.${claims}`;
|
|
119
|
+
const signature = await globalThis.crypto.subtle.sign('RSASSA-PKCS1-v1_5', await signingKey, utf8(signing));
|
|
120
|
+
return `${signing}.${b64url(new Uint8Array(signature))}`;
|
|
121
|
+
};
|
|
122
|
+
const trade = () => {
|
|
123
|
+
inFlight ??= (async () => {
|
|
124
|
+
const started = now();
|
|
125
|
+
const response = await doFetch(`${issuer}/oauth/v2/token`, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
128
|
+
body: new URLSearchParams({
|
|
129
|
+
grant_type: JWT_BEARER_GRANT,
|
|
130
|
+
scope,
|
|
131
|
+
assertion: await assertion(started),
|
|
132
|
+
}).toString(),
|
|
133
|
+
});
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
const text = (await response.text()).slice(0, 512);
|
|
136
|
+
throw new Error(`the token endpoint refused the machine key (${response.status}): ${text}`);
|
|
137
|
+
}
|
|
138
|
+
const body = (await response.json());
|
|
139
|
+
if (!body.access_token) {
|
|
140
|
+
throw new Error('the token endpoint answered no access_token');
|
|
141
|
+
}
|
|
142
|
+
const life = body.expires_in ?? 900;
|
|
143
|
+
const early = Math.min(MACHINE_KEY_REFRESH_EARLY_S, life / 2);
|
|
144
|
+
cached = { token: body.access_token, refreshAt: started + (life - early) * 1000 };
|
|
145
|
+
return body.access_token;
|
|
146
|
+
})()
|
|
147
|
+
.catch((error) => {
|
|
148
|
+
cached = undefined;
|
|
149
|
+
throw error;
|
|
150
|
+
})
|
|
151
|
+
.finally(() => {
|
|
152
|
+
inFlight = undefined;
|
|
153
|
+
});
|
|
154
|
+
return inFlight;
|
|
155
|
+
};
|
|
156
|
+
return {
|
|
157
|
+
userId: file.userId,
|
|
158
|
+
token: async () => {
|
|
159
|
+
if (cached !== undefined && now() < cached.refreshAt) {
|
|
160
|
+
return cached.token;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
return await trade();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// A failed trade sends no credential; `refresh()` reports the reason.
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
refresh: async () => {
|
|
171
|
+
await trade();
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -25,13 +25,15 @@ export async function* paginate(binding, fetch, request, options = {}) {
|
|
|
25
25
|
...request,
|
|
26
26
|
...(pageToken === undefined ? {} : { [pageTokenField]: pageToken }),
|
|
27
27
|
}, options));
|
|
28
|
-
|
|
28
|
+
// The binding spells the field as the proto does (`service_accounts`);
|
|
29
|
+
// a decoded message has it in TypeScript case (`serviceAccounts`).
|
|
30
|
+
const items = page[pagination.items] ?? page[camelCase(pagination.items)];
|
|
29
31
|
if (Array.isArray(items)) {
|
|
30
32
|
for (const item of items) {
|
|
31
33
|
yield item;
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
|
-
const next = page[pagination.nextPageToken];
|
|
36
|
+
const next = page[pagination.nextPageToken] ?? page[camelCase(pagination.nextPageToken)];
|
|
35
37
|
if (typeof next !== 'string' || next === '') {
|
|
36
38
|
return;
|
|
37
39
|
}
|
|
@@ -45,3 +47,7 @@ export function pageFields(binding) {
|
|
|
45
47
|
}
|
|
46
48
|
return { pageSizeField: 'pageSize', pageTokenField: 'pageToken' };
|
|
47
49
|
}
|
|
50
|
+
/** `snake_case` to `lowerCamelCase`, as protobuf-es names a field. */
|
|
51
|
+
function camelCase(name) {
|
|
52
|
+
return name.replace(/_([a-z0-9])/g, (_match, letter) => letter.toUpperCase());
|
|
53
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** The first and the largest pause after an empty fetch. */
|
|
2
|
+
export declare const SUBSCRIBE_MIN_BACKOFF_MS = 100;
|
|
3
|
+
export declare const SUBSCRIBE_MAX_BACKOFF_MS = 1000;
|
|
4
|
+
/** Where a subscription starts and how much each fetch asks for. */
|
|
5
|
+
export interface SubscribeRequest {
|
|
6
|
+
namespace?: string;
|
|
7
|
+
stream: string;
|
|
8
|
+
partition?: number;
|
|
9
|
+
/** The first offset to read; default 0. */
|
|
10
|
+
fromOffset?: bigint | number;
|
|
11
|
+
maxRecords?: number;
|
|
12
|
+
maxBytes?: number;
|
|
13
|
+
/** A server long poll per fetch, when the server offers it (P0c). */
|
|
14
|
+
maxWaitMs?: number;
|
|
15
|
+
}
|
|
16
|
+
/** One record, as far as the loop needs to know it. */
|
|
17
|
+
export interface OffsetRecord {
|
|
18
|
+
readonly offset: bigint | number | string;
|
|
19
|
+
}
|
|
20
|
+
/** One `Fetch`. */
|
|
21
|
+
export type RecordFetcher<R extends OffsetRecord> = (request: Record<string, unknown>, options?: {
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}) => Promise<{
|
|
24
|
+
records?: readonly R[];
|
|
25
|
+
} | undefined>;
|
|
26
|
+
/** A running subscription: an async iterable that knows where it is. */
|
|
27
|
+
export interface Subscription<R> extends AsyncIterableIterator<R> {
|
|
28
|
+
/** The next offset to read. */
|
|
29
|
+
readonly offset: bigint;
|
|
30
|
+
/** Stops the loop; the same as `return()`. */
|
|
31
|
+
cancel(): void;
|
|
32
|
+
}
|
|
33
|
+
/** Starts a subscription over `fetch`. Nothing is sent until the first
|
|
34
|
+
* `next()`. */
|
|
35
|
+
export declare function subscribe<R extends OffsetRecord>(fetch: RecordFetcher<R>, request: SubscribeRequest, options?: {
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}): Subscription<R>;
|