@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.
Files changed (56) hide show
  1. package/README.md +104 -4
  2. package/dist/app.d.ts +149 -0
  3. package/dist/app.js +317 -0
  4. package/dist/builders/filter.d.ts +96 -0
  5. package/dist/builders/filter.js +190 -0
  6. package/dist/builders/schema.d.ts +50 -0
  7. package/dist/builders/schema.js +92 -0
  8. package/dist/builders/search.d.ts +159 -0
  9. package/dist/builders/search.js +307 -0
  10. package/dist/gen/facade.d.ts +267 -2
  11. package/dist/gen/facade.js +194 -0
  12. package/dist/handle.d.ts +67 -0
  13. package/dist/handle.js +107 -0
  14. package/dist/index.d.ts +13 -5
  15. package/dist/index.js +13 -3
  16. package/dist/loams.d.ts +78 -3
  17. package/dist/loams.js +84 -6
  18. package/dist/node.js +1 -1
  19. package/dist/runtime/batch.d.ts +29 -0
  20. package/dist/runtime/batch.js +95 -0
  21. package/dist/runtime/call.d.ts +63 -9
  22. package/dist/runtime/call.js +243 -41
  23. package/dist/runtime/consistency.d.ts +65 -11
  24. package/dist/runtime/consistency.js +212 -46
  25. package/dist/runtime/errors.d.ts +29 -2
  26. package/dist/runtime/errors.js +26 -0
  27. package/dist/runtime/machine-key.d.ts +33 -0
  28. package/dist/runtime/machine-key.js +174 -0
  29. package/dist/runtime/pagination.js +8 -2
  30. package/dist/runtime/subscribe.d.ts +37 -0
  31. package/dist/runtime/subscribe.js +128 -0
  32. package/dist/runtime/token-source.d.ts +1 -1
  33. package/dist/runtime/token-source.js +1 -1
  34. package/dist/runtime/transports.d.ts +22 -1
  35. package/dist/runtime/transports.js +28 -2
  36. package/dist/vendor/proto/collection-v1.d.ts +3 -0
  37. package/dist/vendor/proto/collection-v1.js +7 -0
  38. package/dist/vendor/proto/gen/loams/auth/v1/auth_pb.d.ts +7 -2
  39. package/dist/vendor/proto/gen/loams/auth/v1/auth_pb.js +5 -1
  40. package/dist/vendor/proto/gen/loams/collection/v1/collection_pb.d.ts +1610 -0
  41. package/dist/vendor/proto/gen/loams/collection/v1/collection_pb.js +399 -0
  42. package/dist/vendor/proto/gen/loams/collection/v1/document_pb.d.ts +1054 -0
  43. package/dist/vendor/proto/gen/loams/collection/v1/document_pb.js +327 -0
  44. package/dist/vendor/proto/gen/loams/collection/v1/query_pb.d.ts +2101 -0
  45. package/dist/vendor/proto/gen/loams/collection/v1/query_pb.js +608 -0
  46. package/dist/vendor/proto/gen/loams/graph/v1/graph_pb.d.ts +2299 -0
  47. package/dist/vendor/proto/gen/loams/graph/v1/graph_pb.js +596 -0
  48. package/dist/vendor/proto/gen/loams/instance/v1/instance_pb.d.ts +23 -7
  49. package/dist/vendor/proto/gen/loams/instance/v1/instance_pb.js +20 -5
  50. package/dist/vendor/proto/gen/loams/operations/v1/operations_pb.d.ts +399 -0
  51. package/dist/vendor/proto/gen/loams/operations/v1/operations_pb.js +118 -0
  52. package/dist/vendor/proto/gen/loams/sql/v1/sql_pb.d.ts +182 -0
  53. package/dist/vendor/proto/gen/loams/sql/v1/sql_pb.js +72 -0
  54. package/dist/vendor/proto/gen/loams/stream/v1/stream_pb.d.ts +688 -0
  55. package/dist/vendor/proto/gen/loams/stream/v1/stream_pb.js +180 -0
  56. 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 `consistency_token`; a read accepts one, so a caller
4
- // that just wrote can read its own write. Threading those by hand is the
5
- // caller's job today. The session store is the alternative §44 §7.4 asks for:
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
- // **The token's encoding is not in the protos yet.** §05 §5 defines the
10
- // semantics (offsets per stream and partition, `STRONG` as the default,
11
- // `EVENTUAL`, `AT_LEAST{token}`) and API1's write paths carry it as an opaque
12
- // `v1:` string; §44 §7.4 says it merges by "max offset per stream and
13
- // partition", which needs the encoding to be parsed. Until that lands this
14
- // store keeps the token it was given, refuses to merge two different tokens
15
- // into a wrong one, and says so — a silently-wrong consistency token reads
16
- // stale data, which is worse than an error.
17
- import { LoamsError } from './errors.js';
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) && value.length > TOKEN_PREFIX.length;
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 session's consistency token.
69
+ * A parsed consistency token: `parse`, `toString`, `merge`, `equals`.
27
70
  *
28
- * Merging is deliberately conservative while the encoding is opaque: the store
29
- * keeps what it has, and a second, different token is a conflict the caller has
30
- * to resolve, not something to merge blindly.
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
- #token;
34
- /** How many times two different tokens met. Surfaced so the limitation is
35
- * visible rather than silent. */
36
- #conflicts = 0;
37
- /** The token to attach to the next read. */
38
- current() {
39
- return this.#token;
40
- }
41
- /** Folds a token the server returned in. */
42
- record(token) {
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
- if (!isConsistencyToken(token)) {
47
- this.#conflicts += 1;
48
- throw new LoamsError(`not a consistency token: ${token}`, { code: Code.Internal });
210
+ let parsed;
211
+ try {
212
+ parsed = typeof token === 'string' ? ConsistencyToken.parse(token) : token;
49
213
  }
50
- if (this.#token === undefined || this.#token === token) {
51
- this.#token = token;
52
- return;
214
+ catch (error) {
215
+ this.#rejected += 1;
216
+ throw error;
53
217
  }
54
- this.#conflicts += 1;
55
- throw new LoamsError('two different consistency tokens met and the encoding cannot merge them yet; ' +
56
- 'the session keeps the first. Merging by stream and partition offset arrives with ' +
57
- 'the write paths that carry offsets (§44 §7.4, D609).', { code: Code.FailedPrecondition });
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
- /** How many unmergeable pairs the session has seen. */
225
+ /** @deprecated The old name of `rejected`, from before tokens merged. */
60
226
  get conflicts() {
61
- return this.#conflicts;
227
+ return this.#rejected;
62
228
  }
63
- /** Forgets the token, so the next read is not held to it. */
229
+ /** Forgets every token, so the next read is not held to one. */
64
230
  clear() {
65
- this.#token = undefined;
66
- this.#conflicts = 0;
231
+ this.#tokens.clear();
232
+ this.#rejected = 0;
67
233
  }
68
234
  }
@@ -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 {
@@ -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
- const items = page[pagination.items];
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>;