euclid-ndk 0.2.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.
Files changed (127) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +603 -0
  3. package/dist/auth/index.d.ts +18 -0
  4. package/dist/auth/index.d.ts.map +1 -0
  5. package/dist/auth/index.js +17 -0
  6. package/dist/auth/index.js.map +1 -0
  7. package/dist/auth/rfc9421.d.ts +112 -0
  8. package/dist/auth/rfc9421.d.ts.map +1 -0
  9. package/dist/auth/rfc9421.js +452 -0
  10. package/dist/auth/rfc9421.js.map +1 -0
  11. package/dist/auth/scheme.d.ts +46 -0
  12. package/dist/auth/scheme.d.ts.map +1 -0
  13. package/dist/auth/scheme.js +44 -0
  14. package/dist/auth/scheme.js.map +1 -0
  15. package/dist/auth/signable.d.ts +37 -0
  16. package/dist/auth/signable.d.ts.map +1 -0
  17. package/dist/auth/signable.js +70 -0
  18. package/dist/auth/signable.js.map +1 -0
  19. package/dist/auth/sigv4.d.ts +72 -0
  20. package/dist/auth/sigv4.d.ts.map +1 -0
  21. package/dist/auth/sigv4.js +227 -0
  22. package/dist/auth/sigv4.js.map +1 -0
  23. package/dist/credentials.d.ts +56 -0
  24. package/dist/credentials.d.ts.map +1 -0
  25. package/dist/credentials.js +135 -0
  26. package/dist/credentials.js.map +1 -0
  27. package/dist/dto/com.d.ts +101 -0
  28. package/dist/dto/com.d.ts.map +1 -0
  29. package/dist/dto/com.js +132 -0
  30. package/dist/dto/com.js.map +1 -0
  31. package/dist/dto/eag.d.ts +104 -0
  32. package/dist/dto/eag.d.ts.map +1 -0
  33. package/dist/dto/eag.js +70 -0
  34. package/dist/dto/eag.js.map +1 -0
  35. package/dist/dto/eam.d.ts +105 -0
  36. package/dist/dto/eam.d.ts.map +1 -0
  37. package/dist/dto/eam.js +98 -0
  38. package/dist/dto/eam.js.map +1 -0
  39. package/dist/dto/eap.d.ts +88 -0
  40. package/dist/dto/eap.d.ts.map +1 -0
  41. package/dist/dto/eap.js +54 -0
  42. package/dist/dto/eap.js.map +1 -0
  43. package/dist/dto/ekm.d.ts +109 -0
  44. package/dist/dto/ekm.d.ts.map +1 -0
  45. package/dist/dto/ekm.js +78 -0
  46. package/dist/dto/ekm.js.map +1 -0
  47. package/dist/dto/ekv.d.ts +73 -0
  48. package/dist/dto/ekv.d.ts.map +1 -0
  49. package/dist/dto/ekv.js +45 -0
  50. package/dist/dto/ekv.js.map +1 -0
  51. package/dist/dto/ens.d.ts +84 -0
  52. package/dist/dto/ens.d.ts.map +1 -0
  53. package/dist/dto/ens.js +72 -0
  54. package/dist/dto/ens.js.map +1 -0
  55. package/dist/dto/eqs.d.ts +160 -0
  56. package/dist/dto/eqs.d.ts.map +1 -0
  57. package/dist/dto/eqs.js +120 -0
  58. package/dist/dto/eqs.js.map +1 -0
  59. package/dist/dto/esm.d.ts +192 -0
  60. package/dist/dto/esm.d.ts.map +1 -0
  61. package/dist/dto/esm.js +147 -0
  62. package/dist/dto/esm.js.map +1 -0
  63. package/dist/dto/ess.d.ts +49 -0
  64. package/dist/dto/ess.d.ts.map +1 -0
  65. package/dist/dto/ess.js +30 -0
  66. package/dist/dto/ess.js.map +1 -0
  67. package/dist/dto/json.d.ts +24 -0
  68. package/dist/dto/json.d.ts.map +1 -0
  69. package/dist/dto/json.js +47 -0
  70. package/dist/dto/json.js.map +1 -0
  71. package/dist/errors.d.ts +40 -0
  72. package/dist/errors.d.ts.map +1 -0
  73. package/dist/errors.js +73 -0
  74. package/dist/errors.js.map +1 -0
  75. package/dist/http/client.d.ts +96 -0
  76. package/dist/http/client.d.ts.map +1 -0
  77. package/dist/http/client.js +258 -0
  78. package/dist/http/client.js.map +1 -0
  79. package/dist/index.d.ts +93 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +110 -0
  82. package/dist/index.js.map +1 -0
  83. package/dist/modules/base.d.ts +121 -0
  84. package/dist/modules/base.d.ts.map +1 -0
  85. package/dist/modules/base.js +142 -0
  86. package/dist/modules/base.js.map +1 -0
  87. package/dist/modules/eag.d.ts +166 -0
  88. package/dist/modules/eag.d.ts.map +1 -0
  89. package/dist/modules/eag.js +189 -0
  90. package/dist/modules/eag.js.map +1 -0
  91. package/dist/modules/eam.d.ts +314 -0
  92. package/dist/modules/eam.d.ts.map +1 -0
  93. package/dist/modules/eam.js +641 -0
  94. package/dist/modules/eam.js.map +1 -0
  95. package/dist/modules/eap.d.ts +204 -0
  96. package/dist/modules/eap.d.ts.map +1 -0
  97. package/dist/modules/eap.js +218 -0
  98. package/dist/modules/eap.js.map +1 -0
  99. package/dist/modules/ekm.d.ts +182 -0
  100. package/dist/modules/ekm.d.ts.map +1 -0
  101. package/dist/modules/ekm.js +228 -0
  102. package/dist/modules/ekm.js.map +1 -0
  103. package/dist/modules/ekv.d.ts +189 -0
  104. package/dist/modules/ekv.d.ts.map +1 -0
  105. package/dist/modules/ekv.js +212 -0
  106. package/dist/modules/ekv.js.map +1 -0
  107. package/dist/modules/ens.d.ts +122 -0
  108. package/dist/modules/ens.d.ts.map +1 -0
  109. package/dist/modules/ens.js +167 -0
  110. package/dist/modules/ens.js.map +1 -0
  111. package/dist/modules/eqs.d.ts +283 -0
  112. package/dist/modules/eqs.d.ts.map +1 -0
  113. package/dist/modules/eqs.js +355 -0
  114. package/dist/modules/eqs.js.map +1 -0
  115. package/dist/modules/esm.d.ts +358 -0
  116. package/dist/modules/esm.d.ts.map +1 -0
  117. package/dist/modules/esm.js +660 -0
  118. package/dist/modules/esm.js.map +1 -0
  119. package/dist/modules/ess.d.ts +96 -0
  120. package/dist/modules/ess.d.ts.map +1 -0
  121. package/dist/modules/ess.js +110 -0
  122. package/dist/modules/ess.js.map +1 -0
  123. package/dist/url.d.ts +31 -0
  124. package/dist/url.d.ts.map +1 -0
  125. package/dist/url.js +47 -0
  126. package/dist/url.js.map +1 -0
  127. package/package.json +47 -0
package/README.md ADDED
@@ -0,0 +1,603 @@
1
+ # euclid-ndk
2
+
3
+ Node.js client library for the [euclid](https://github.com/jensvogt/euclid) server.
4
+
5
+ Nine modules so far. EAM - euclid's access management module - is where a login comes from; ESM
6
+ (storage), EQS (queues), ENS (notifications), EKM (keys), EKV (tables), EAP (applications), ESS
7
+ (secrets) and EAG (the API gateway) are reached from the session it hands back. The remaining modules
8
+ (EES, ETS) speak the same protocol over the same client and will follow.
9
+
10
+ Requires Node 20 or newer, and **has no dependencies**. Installing this SDK does not bring a TLS
11
+ stack, an HTTP client and a JSON parser along with it: the wire protocol is JSON over HTTP and the
12
+ signatures are HMAC-SHA256, all of which node's standard library already covers. TypeScript is a
13
+ development dependency, not a runtime one - what npm ships is JavaScript with its declarations
14
+ beside it.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install euclid-ndk
20
+ ```
21
+
22
+ ESM only, with types: `import { Euclid } from "euclid-ndk"`. A CommonJS application can reach it
23
+ with `await import("euclid-ndk")`.
24
+
25
+ ## Usage
26
+
27
+ Log in once and reuse the session:
28
+
29
+ ```ts
30
+ import { Euclid } from "euclid-ndk";
31
+
32
+ const session = await Euclid.forServer("https://euclid.example.com").login("jens", "secret");
33
+
34
+ const users = await session.listUsers({ prefix: "j", pageSize: 25 });
35
+ for (const user of users.items) console.log(user.userId, user.email);
36
+
37
+ session.close();
38
+ ```
39
+
40
+ The builder form takes the same options one at a time, which reads better when there are several:
41
+
42
+ ```ts
43
+ import { Euclid, RFC9421 } from "euclid-ndk";
44
+
45
+ const session = await Euclid.forServer("https://euclid.example.com")
46
+ .access()
47
+ .credentials("jens", "secret")
48
+ .namespace("development")
49
+ .signingScheme(RFC9421)
50
+ .caCertPath("/etc/euclid/euclid_cert.crt")
51
+ .login();
52
+ ```
53
+
54
+ A session holds a connection, so close it when you are done:
55
+
56
+ ```ts
57
+ try {
58
+ await session.createAccount("111", "acme", "an account");
59
+ } finally {
60
+ session.close();
61
+ }
62
+ ```
63
+
64
+ Every listing answers with a page - `total` is how many exist, `items` is the page:
65
+
66
+ ```ts
67
+ const { total, items } = await session.listAccounts({ pageSize: 5 });
68
+ ```
69
+
70
+ The other modules hang off that session - `session.esm()`, `session.eqs()`, `session.ens()`,
71
+ `session.ekm()`, `session.ekv()`, `session.eap()`, `session.ess()`, `session.eag()` - and each answers
72
+ with the same client every time, so asking for one inside a loop costs one connection rather than one
73
+ per iteration:
74
+
75
+ ```ts
76
+ const esm = session.esm();
77
+ const bucket = await esm.createBucket("reports");
78
+ await esm.uploadFile(bucket.ern, "2026/q3.pdf", "q3.pdf");
79
+ ```
80
+
81
+ ### The credentials cache
82
+
83
+ `login()` writes `~/.euclid/credentials` and reads it back on the next call, so logging in twice
84
+ costs one round trip. It is the same file `euclid-cli`, `euclid-jdk` and `euclid-pdk` use, with the
85
+ same field names, so a login from any of the four is picked up by the others.
86
+ `EUCLID_CREDENTIALS_FILE` overrides the path, which is also how euclid hands a managed application
87
+ its own credentials.
88
+
89
+ Pass `useCache(false)` to force a fresh login and leave the file alone.
90
+
91
+ ### Signing
92
+
93
+ A login returns two credentials: a bearer token, and - when the user has one - an access key and
94
+ secret. By default a session signs with the access key when it has one and presents the token
95
+ otherwise, which is what `AUTH_AUTO` means. euclid accepts either for every action.
96
+
97
+ ```ts
98
+ import { AUTH_BEARER } from "euclid-ndk";
99
+
100
+ const session = await Euclid.forServer(url).login("jens", "secret", { auth: AUTH_BEARER });
101
+ ```
102
+
103
+ Two signing schemes are implemented, both keyed by the same access key and secret:
104
+
105
+ | Scheme | Where the signature travels | Notes |
106
+ | --- | --- | --- |
107
+ | `SIGV4` | `Authorization`, plus `x-amz-date` and `x-amz-content-sha256` | The default, and what euclid has understood from the start |
108
+ | `RFC9421` | `Signature` and `Signature-Input`, plus `Content-Digest` | [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html) HTTP Message Signatures, the standard scheme meant to replace it |
109
+
110
+ Both cover a **fixed** set of headers rather than a set the request declares: the method, path and
111
+ authority, the body digest, and the `x-euclid-account-id`, `x-euclid-action`, `x-euclid-region`,
112
+ `x-euclid-target` and `x-euclid-user-id` headers that carry what the request is asking for and on
113
+ whose behalf. euclid's server compares that list against its own for exact equality, so a signature
114
+ covering more, fewer, or the same components in another order is rejected. `x-euclid-namespace` is
115
+ *not* covered - a real gap rather than a simplification, and one that has to be closed on both sides
116
+ at once.
117
+
118
+ Verification is implemented too, not just signing, and is what the test suite's fake gateway uses:
119
+
120
+ ```ts
121
+ import { SignableRequest, signingSchemeOf } from "euclid-ndk";
122
+
123
+ const request = new SignableRequest("POST", "/")
124
+ .headersFrom(incomingHeaders)
125
+ .setBody(incomingBody)
126
+ .setScheme("https");
127
+
128
+ const scheme = signingSchemeOf(request);
129
+ const keyId = scheme?.verify(request, lookupSecret) ?? null;
130
+ ```
131
+
132
+ ### TLS
133
+
134
+ `https://` URLs are verified against the system trust store. A euclid deployment usually presents
135
+ its own certificate, so `/etc/euclid/euclid_cert.crt` is trusted *alongside* the system store when
136
+ that file exists - the same default `euclid-cli --ca-cert` uses. Node replaces the trust store when
137
+ it is given a CA, so the union is spelled out here rather than assumed. Point `caCertPath` elsewhere,
138
+ or pass `verify(false)` for a development server whose certificate nothing vouches for.
139
+
140
+ ### Errors
141
+
142
+ | Error | Thrown when |
143
+ | --- | --- |
144
+ | `EuclidAuthenticationError` | a login was refused |
145
+ | `EuclidServiceError` | a module refused or failed an action; carries `target`, `action`, `status` and `reason` |
146
+ | `EuclidError` | base class for both |
147
+
148
+ `reason` is the server's own message, pulled out of the `{"error": "..."}` body every euclid module
149
+ answers failures with.
150
+
151
+ ### Retries
152
+
153
+ Two, both narrow on purpose:
154
+
155
+ * A request that failed because the connection was closed while it sat idle is sent again once, on a
156
+ fresh connection. Only failures that produced no response at all qualify.
157
+ * A 401 whose body says the credentials had expired is sent again once with rebuilt headers, if
158
+ rebuilding them produces something different. A wrong password or a missing permission is answered
159
+ once, as before.
160
+
161
+ For a process that outlives its token, set `session.tokenProvider` to something that re-reads the
162
+ credentials file; the retry then has a fresh token to use.
163
+
164
+ ## What EAM covers
165
+
166
+ | Method | Action |
167
+ | --- | --- |
168
+ | `listUsers`, `register`, `deleteUser` | users |
169
+ | `createAccessKey`, `listAccessKeys`, `deleteAccessKey` | the caller's own signing credentials |
170
+ | `createUserGroup`, `listUserGroups`, `deleteUserGroup`, `addUserToUserGroup`, `removeUserFromUserGroup` | groups |
171
+ | `createAccount`, `listAccounts`, `deleteAccount` | accounts |
172
+ | `createNamespace`, `listNamespaces`, `deleteNamespace`, `grantNamespaceAccess`, `revokeNamespaceAccess` | namespaces |
173
+ | `changeNamespace` | which namespace this session is scoped to |
174
+ | `metrics` | EAM's own metrics |
175
+ | `call(action, payload)` | anything the server gained that this SDK has not wrapped yet |
176
+
177
+ Several of these are administrator-only server-side; `session.isAdmin` says whether the logged-in
178
+ user is one, though the server enforces it regardless.
179
+
180
+ ## What ESM covers
181
+
182
+ `session.esm()` answers with the storage client, and with the same one every time - a connection per
183
+ module rather than per call. It follows the session it came from: a `changeNamespace` between two
184
+ calls scopes the second one.
185
+
186
+ | Method | Action |
187
+ | --- | --- |
188
+ | `createBucket`, `listBuckets`, `getBucketErn`, `getBucketSize`, `renameBucket`, `purgeBucket`, `deleteBucket` | buckets |
189
+ | `addBucketTag`, `setBucketTag`, `deleteBucketTag` | bucket tags |
190
+ | `enableEncryption`, `disableEncryption` | encryption at rest, under an EKM key |
191
+ | `setBucketInternal` | whether a bucket is euclid's own plumbing, and so left out of a listing |
192
+ | `listObjects`, `getObjectCount`, `copyObject`, `moveObject`, `renameObject`, `deleteObject`, `deleteObjects` | objects |
193
+ | `touchObject` | re-announce objects already stored, for a listener that missed their events |
194
+ | `addObjectAttribute`, `setObjectAttribute`, `listObjectAttributes`, `deleteObjectAttribute` | user-defined attributes |
195
+ | `subscribe`, `listSubscriptions`, `unsubscribe`, `parseBucketEvent` | a bucket's events, into a queue or a topic |
196
+ | `putObject`, `getObject`, `uploadFile`, `downloadFile` | the object's bytes themselves |
197
+ | `metrics` | ESM's own metrics |
198
+ | `call(action, payload)` | anything the server gained that this SDK has not wrapped yet |
199
+
200
+ Buckets and objects are named by ERN, not by name - `createBucket` answers with the one everything
201
+ else takes, and `getBucketErn` is how an existing bucket's is looked up.
202
+
203
+ ### Writing and reading bytes
204
+
205
+ `putObject` and `getObject` are one request each; `uploadFile` and `downloadFile` are the multipart
206
+ path, and are what a file of any size wants:
207
+
208
+ ```ts
209
+ await esm.uploadFile(bucket.ern, "data/large.bin", "large.bin", { partSize: 5 * 1024 * 1024, concurrency: 4 });
210
+ const written = await esm.downloadFile(bucket.ern, "data/large.bin", "copy.bin");
211
+ ```
212
+
213
+ The file is read a part at a time and no more than `concurrency` parts are ever in flight, so what an
214
+ upload costs in memory is bounded by the two together whatever the file's size. A download does not
215
+ know its size before asking, so one request is tried first and the server answering HTTP 413 is what
216
+ says the object needs the parts - a caller does not have to know which of the two an object needs.
217
+
218
+ Each step of a transfer is retried up to four times on a 5xx or a request that got no answer at all,
219
+ including the `create-upload` and `complete-upload` bracketing the parts: those run once per transfer
220
+ rather than once per part, but giving up on a transient failure in one of them discards the whole
221
+ file. A 4xx is answered once, since a repeat would be answered identically.
222
+
223
+ These four actions - `put-object`, `get-object`, `upload-part`, `download-part` - carry the object's
224
+ bytes as the request body, with the bucket, key and part number riding as headers, which is what
225
+ keeps a 5 MiB part 5 MiB on the wire rather than a third larger as base64 inside JSON. They also
226
+ present the session's bearer token rather than a signature, which is what euclid-cli, euclid-jdk and
227
+ euclid-pdk do for the same four, so every client writes objects the same way. A session that asked
228
+ for `AUTH_SIGNATURE` signs them anyway: it asked not to be handed a token silently.
229
+
230
+ ### Attributes
231
+
232
+ An object carries two attribute maps, and they are not the same one. `attributes` are the caller's
233
+ own, listed back by `listObjectAttributes` and meaningless to euclid; `systemAttributes` are euclid's
234
+ envelope, which travels with the object across every hop. The one euclid acts on is `priority`.
235
+
236
+ ```ts
237
+ import { PRIORITY_LOW } from "euclid-ndk";
238
+
239
+ await esm.putObject(bucket.ern, "notes/hello.txt", Buffer.from("hello\n"), {
240
+ attributes: { author: "euclid-ndk", revision: 1 },
241
+ systemAttributes: { priority: PRIORITY_LOW },
242
+ });
243
+ ```
244
+
245
+ Values are typed on the wire: a plain value is tagged with the type euclid stores it under - a whole
246
+ number as `long`, anything else numeric as `double`, a `Uint8Array` as `binary` and base64 encoded -
247
+ and `{ type: "int", value: 3 }` is how a caller asks for a tag other than the obvious one. `binary`
248
+ comes back as a `Buffer`, so the encoding never reaches the caller.
249
+
250
+ Attributes belong on the write rather than added afterwards: completing an upload is finished off in
251
+ the background from what that call carried, so an attribute added between the two is overwritten.
252
+
253
+ ### Subscriptions
254
+
255
+ A subscription announces a bucket's object events to a queue or a topic, filtered by the server as it
256
+ publishes. What lands there is an ordinary message whose body is the event:
257
+
258
+ ```ts
259
+ import { OBJECT_CREATED, QUEUE, parseBucketEvent } from "euclid-ndk";
260
+
261
+ await esm.subscribe(bucket.ern, QUEUE, queueErn, { eventTypes: [OBJECT_CREATED], prefix: "2026/" });
262
+ const event = parseBucketEvent(message.body);
263
+ ```
264
+
265
+ `unsubscribe` takes the subscription's own ERN - not the bucket's, and not the target's. Subscribing
266
+ is not idempotent: a second call delivers every matching event twice, so a caller that may run twice
267
+ checks `listSubscriptions` first.
268
+
269
+ ## What EQS covers
270
+
271
+ `session.eqs()` answers with the queue client.
272
+
273
+ | Method | Action |
274
+ | --- | --- |
275
+ | `createQueue`, `listQueues`, `getQueueErn`, `getQueueMetadata`, `purgeQueue`, `purgeAllQueues`, `deleteQueue` | queues |
276
+ | `addQueueTag`, `setQueueTag`, `deleteQueueTag` | queue tags |
277
+ | `stopQueue`, `startQueue`, `setQueueVisibility` | what a queue hands out, and for how long |
278
+ | `sendMessage`, `receiveMessages`, `receiveAllMessages`, `deleteMessage`, `deleteMessageById` | messages |
279
+ | `listMessages`, `getMessageCount`, `getMessageMetadata` | inspecting a queue without consuming it |
280
+ | `getMessageAttribute`, `setMessageAttribute`, `setMessageVisibility` | one message at a time |
281
+ | `redriveDlq` | moving a dead letter queue's messages back where they came from |
282
+ | `asInternal`, `metrics` | euclid's own traffic, and EQS's own metrics |
283
+ | `call(action, payload)` | anything the server gained that this SDK has not wrapped yet |
284
+
285
+ Receiving is a lease rather than a read. A message a consumer takes is invisible to every other
286
+ consumer until its visibility timeout expires, and deleting it with the receipt handle is what says the
287
+ work was done - so the delete belongs after the work, not before it:
288
+
289
+ ```ts
290
+ const eqs = session.eqs();
291
+ const queue = await eqs.createQueue("orders", { visibility: 30, maxRetries: 5, dlqName: "orders-dlq" });
292
+
293
+ await eqs.sendMessage(queue.ern, JSON.stringify({ order: 17 }), { attributes: { tenant: "acme" } });
294
+
295
+ for (const message of (await eqs.receiveMessages(queue.ern, { waitTimeSeconds: 20 })).items) {
296
+ await handle(message.body);
297
+ await eqs.deleteMessage(message.receiptHandle);
298
+ }
299
+ ```
300
+
301
+ A consumer that dies instead simply stops holding the lease and the message comes back; after
302
+ `maxRetries` deliveries it goes to the dead letter queue, where `getMessageMetadata` explains why.
303
+
304
+ `waitTimeSeconds` is a long poll, and the waiting is the server's: it holds the request open until a
305
+ message lands or the window runs out, so an idle queue costs one request for the whole window rather
306
+ than one per tick, and the request gets a timeout of its own rather than the session's. Two details are
307
+ the client's:
308
+
309
+ * With no wait asked for, the queue's depth is checked first - a receive is a write, and one that takes
310
+ nothing is work the server did for nothing.
311
+ * The server keeps a bounded number of long-poll slots so that waiting consumers cannot starve the
312
+ producers sending to them. With none free it answers at once, which comes back empty with time still
313
+ on the clock; the client then pauses briefly and asks again for what is left of the window, rather
314
+ than hammering a server that is already short of threads.
315
+
316
+ `asInternal()` marks a client's requests as euclid's own traffic. The same `get-message-count` is a
317
+ user's question one moment and a metric collector's poll the next, and only the caller knows which, so
318
+ instrumentation says so rather than leaving the server to guess from a rate.
319
+
320
+ ## What ENS covers
321
+
322
+ `session.ens()` answers with the notification client.
323
+
324
+ | Method | Action |
325
+ | --- | --- |
326
+ | `createTopic`, `listTopics`, `getTopicErn`, `getTopicMetadata`, `purgeTopic`, `purgeAllTopics`, `deleteTopic` | topics |
327
+ | `addTopicTag`, `setTopicTag`, `deleteTopicTag` | topic tags |
328
+ | `publishMessage`, `listMessages`, `getMessageCount` | messages |
329
+ | `getMessageAttribute`, `setMessageAttribute` | one published message at a time |
330
+ | `subscribe`, `listSubscriptions`, `unsubscribe` | delivery onward to a queue |
331
+ | `metrics` | ENS's own metrics |
332
+ | `call(action, payload)` | anything the server gained that this SDK has not wrapped yet |
333
+
334
+ What a topic does with a message is the whole difference from a queue: a queue holds one until a
335
+ consumer takes it, a topic hands each one to every subscriber and keeps it as a record of having done
336
+ so. There is no receive here and no receipt handle - a subscriber consumes from its own queue:
337
+
338
+ ```ts
339
+ await ens.subscribe(topic.ern, await eqs.getQueueErn("orders"));
340
+ await ens.publishMessage(topic.ern, JSON.stringify({ order: 17 }), { priority: PRIORITY_HIGH });
341
+ ```
342
+
343
+ Each subscriber consumes independently, so one that is slow or stopped delays nobody else. A message
344
+ published before a subscription existed is not delivered retrospectively, and one already delivered is
345
+ not withdrawn when the subscription goes. Subscribing is not idempotent, as in ESM.
346
+
347
+ A topic's counters are not a queue's: `available`, `send` and `resend` count delivery rather than a
348
+ backlog, since a topic does not hold one.
349
+
350
+ One wire asymmetry is reproduced rather than papered over: an attribute's name travels as `name` in
351
+ most of EQS and as `key` throughout ENS, so a request this SDK builds matches what euclid-cli and
352
+ euclid-jdk send.
353
+
354
+ ## What EKM covers
355
+
356
+ `session.ekm()` answers with the key client.
357
+
358
+ | Method | Action |
359
+ | --- | --- |
360
+ | `createKey`, `listKeys`, `setKeyDescription`, `addKeyTag`, `deleteKeyTag` | keys |
361
+ | `revokeKey`, `deleteKey` | taking a key out of use, and out of existence |
362
+ | `encrypt`, `decrypt` | using one |
363
+ | `importCertificate`, `createCertificate`, `getCertificate`, `listCertificates`, `deleteCertificate` | the certificates a deployment serves |
364
+ | `metrics` | EKM's own metrics |
365
+ | `call(action, payload)` | anything the server gained that this SDK has not wrapped yet |
366
+
367
+ Key material never leaves the server: the bytes go to the key rather than the key coming to the bytes.
368
+
369
+ ```ts
370
+ const ekm = session.ekm();
371
+ const key = await ekm.createKey({ description: "customer exports" });
372
+
373
+ const sealed = await ekm.encrypt(key.name, "account 4711"); // IV || ciphertext || tag
374
+ const plain = await ekm.decrypt(key.name, sealed);
375
+ ```
376
+
377
+ A key is named two ways and they are not interchangeable: `name` is the ID the server minted, and is what
378
+ encrypts, decrypts and is deleted; the ERN is what revokes, describes and tags. Both are on every key a
379
+ listing returns, and nothing a listing returns is material.
380
+
381
+ Revoking and deleting are different in the way that matters. A revoked key encrypts nothing further and
382
+ still decrypts what it wrote; `deleteKey` schedules a date - seven days out by default - after which
383
+ everything it encrypted is unreadable. That window is the only chance anybody gets to notice, which is
384
+ why it is a date rather than an act, and a key inside it still decrypts.
385
+
386
+ `encrypt` and `decrypt` carry raw bytes and present the session's bearer token, exactly as ESM's transfer
387
+ actions do, and for the same reasons - including that a session which asked for `AUTH_SIGNATURE` signs
388
+ them anyway. A `description` is worth supplying at creation: a key outlives the reason it was made, and
389
+ months later it is the only thing that answers whether the key can be deleted.
390
+
391
+ ## What EKV covers
392
+
393
+ `session.ekv()` answers with the key-value store.
394
+
395
+ | Method | Action |
396
+ | --- | --- |
397
+ | `createTable`, `describeTable`, `listTables`, `deleteTable` | tables |
398
+ | `putItem`, `getItem`, `findItem`, `deleteItem` | one item at a time |
399
+ | `query` | the items of one partition, in sort-key order |
400
+ | `scan` | a table's items without regard to their key |
401
+ | `metrics` | EKV's own metrics |
402
+ | `call(action, payload)` | anything the server gained that this SDK has not wrapped yet |
403
+
404
+ A table is keyed on one attribute or on two: a partition key that identifies an item, and optionally a sort
405
+ key that orders the items sharing a partition key - which is what makes a partition readable as a range.
406
+
407
+ ```ts
408
+ import { KEY_NUMBER, SORT_GE } from "euclid-ndk";
409
+
410
+ const ekv = session.ekv();
411
+ await ekv.createTable("sessions", "userId", { sortKey: "startedAt", sortKeyType: KEY_NUMBER });
412
+
413
+ await ekv.putItem("sessions", { userId: "jens", startedAt: 1757462400, host: "laptop" });
414
+ const recent = await ekv.query("sessions", "jens", { sortOperator: SORT_GE, sortValue: 1757462400 });
415
+ for (const item of recent.items) console.log(item.attributes.host);
416
+ ```
417
+
418
+ The key types are what make a range mean what it should: a `KEY_NUMBER` sort key orders 2, 9, 10, 100 rather
419
+ than putting "10" before "9". They cannot be changed after the table is created. Everything else about an
420
+ item is a free-form document - scalars, arrays, nested objects - and is *not* the typed `Variant` that EQS,
421
+ ENS and ESM attributes use: EKV stores what JSON can express.
422
+
423
+ `putItem` replaces rather than merges, so changing one field means reading the item, changing it and writing
424
+ the whole thing back. That is why `Item` keeps the server's `_created` and `_modified` out of `attributes`:
425
+ left in, they would be written back as two attributes of the caller's own, and they would stick.
426
+
427
+ `getItem` throws on a miss, because "there is no such item" and "here is an item with nothing in it" are
428
+ different answers and a caller should not have to tell them apart; `findItem` is the same read answering
429
+ `null` instead, and only for a 404. `query` is the lookup EKV is for - it addresses a partition by key -
430
+ while `scan` reads the table, which is right for an export and wrong for a lookup.
431
+
432
+ ## What EAP covers
433
+
434
+ `session.eap()` answers with the application client. Every action is administrator-only server-side;
435
+ `session.isAdmin` says whether the logged-in user is one.
436
+
437
+ | Method | Action |
438
+ | --- | --- |
439
+ | `createApplication`, `updateApplication`, `redeployApplication`, `deleteApplication` | deploying |
440
+ | `startApplication`, `stopApplication`, `listApplications`, `getApplication` | running |
441
+ | `setLogLevel`, `resetLogLevel` | what one application logs, without restarting it |
442
+ | `metrics` | EAP's own metrics |
443
+ | `call(action, payload)` | anything the server gained that this SDK has not wrapped yet |
444
+
445
+ An application is deployed from an artifact already in a bucket - ESM puts it there, EAP names it:
446
+
447
+ ```ts
448
+ import { RUNTIME_JAVA } from "euclid-ndk";
449
+
450
+ await esm.uploadFile(bucketErn, "order-service-1.4.0.jar", "target/order-service.jar");
451
+ await eap.createApplication("order-service", RUNTIME_JAVA, "artifacts", "order-service-1.4.0.jar", {
452
+ queues: ["orders"],
453
+ minInstances: 2,
454
+ maxInstances: 5,
455
+ });
456
+ await eap.startApplication("order-service");
457
+ ```
458
+
459
+ The deployment says which buckets and queues the application may reach, and euclid grants those to the
460
+ identity it runs as: a technical principal it creates unless one is named, with no password, no login and
461
+ one access key, so that nothing an application leaks is a person's credential.
462
+
463
+ Starting asks rather than waits. `desiredState` is what somebody asked for and `state` is what is
464
+ actually answering, so a freshly started application usually comes back `RUNNING`/`STOPPED` - the two
465
+ differing is an application starting up, and the two differing for long is one that cannot.
466
+
467
+ `updateApplication` sends only the fields it is given, because that is the distinction the server draws,
468
+ as in ESS and EAG below:
469
+ leaving `command` out keeps the stored command, while passing `""` clears it and hands the artifact back
470
+ to the runtime's own interpreter. `buckets` and `queues` are re-resolved together whenever either is
471
+ named - so pass both or neither, since naming one revokes what the other granted. For a new build of the
472
+ same application, `redeployApplication` is the call; one that would change neither the version nor the
473
+ checksum is refused, which usually means the new artifact never reached the bucket.
474
+
475
+ ## What ESS covers
476
+
477
+ `session.ess()` answers with the secret store.
478
+
479
+ | Method | Action |
480
+ | --- | --- |
481
+ | `createSecret`, `getSecret`, `listSecrets`, `deleteSecret` | secrets |
482
+ | `rotateSecret`, `updateSecret` | replacing a value, a description, or the key it is under |
483
+ | `addSecretTag`, `deleteSecretTag` | tags |
484
+ | `metrics` | ESS's own metrics |
485
+ | `call(action, payload)` | anything the server gained that this SDK has not wrapped yet |
486
+
487
+ A value is encrypted with EKM before it is stored, so a secret's life is tied to a key's: deleting that key
488
+ there is what makes the value unrecoverable, whatever ESS still says about it.
489
+
490
+ ```ts
491
+ const ess = session.ess();
492
+ await ess.createSecret("db-password", "hunter2", { description: "the reporting database" });
493
+
494
+ const password = (await ess.getSecret("db-password")).value;
495
+ ```
496
+
497
+ `getSecret` is the only call that answers with a value, and so the only point at which one enters the
498
+ process - everything else answers with metadata alone, so a listing, a rotation and a tag change can be
499
+ logged and printed without being the thing that leaks it. `rotateSecret` is `updateSecret` with a value:
500
+ what bumps `version` and sets `rotated`, which together are what an audit of "has this been rotated since
501
+ the incident" actually reads.
502
+
503
+ `updateSecret` sends only what it names, with the same empty-string rule as EAP: leaving `description` out
504
+ keeps the stored one, `""` clears it, and an empty `value` stores an empty value because that is a value
505
+ somebody may legitimately have. Naming a `keyErn` re-encrypts the value under that key, which is how a
506
+ secret is moved off a key that is being retired.
507
+
508
+ ## What EAG covers
509
+
510
+ `session.eag()` answers with the API gateway client. Every action is administrator-only server-side.
511
+
512
+ | Method | Action |
513
+ | --- | --- |
514
+ | `createRoute`, `createModuleRoute`, `updateRoute`, `getRoute`, `listRoutes`, `deleteRoute` | published paths |
515
+ | `setRouteActive` | taking one out of service, and putting it back |
516
+ | `listListeners` | the ports the gateway answers on, and whether it is answering |
517
+ | `metrics` | EAG's own metrics |
518
+ | `call(action, payload)` | anything the server gained that this SDK has not wrapped yet |
519
+
520
+ A route publishes a path prefix and says where everything beneath it goes: to an application euclid runs,
521
+ or to one action of a euclid module. It is one or the other - never both, never neither - and this client
522
+ refuses the other two before the round trip:
523
+
524
+ ```ts
525
+ import { ROUTE_AUTH_EUCLID } from "euclid-ndk";
526
+
527
+ await eag.createRoute("orders", "/api/orders", {
528
+ applicationId: "order-service",
529
+ methods: ["GET", "POST"], // none means every method
530
+ authentication: ROUTE_AUTH_EUCLID, // or ROUTE_AUTH_NONE, or ROUTE_AUTH_BASIC
531
+ });
532
+ await eag.createModuleRoute("login", "/euclid/login", "eam", "login");
533
+ ```
534
+
535
+ Module routes are the way in for something outside euclid that needs euclid itself - a browser that has to
536
+ log in before it can call anything. Without one, a front end would talk to the API gateway for the
537
+ application and to euclid's own gateway for its credentials: two ports, two origins, and CORS between them.
538
+
539
+ `namespace` and `region` are left out of the request entirely unless named, because this server reads an
540
+ empty namespace as *the empty namespace* rather than as "unspecified" - sending one would scope the route
541
+ to nothing. `setRouteActive` is how something stops being exposed in a hurry: the route stays exactly as it
542
+ was and comes back the same, which deleting and recreating it would not guarantee.
543
+
544
+ `listListeners` answers with a page of listeners plus `serving`, which says whether the gateway's ports are
545
+ bound at all. A listener whose port was taken, or whose certificate could not be loaded, is still listed -
546
+ it is the one somebody is looking for. An HTTPS listener's certificate arrives flat, as a dozen
547
+ `certificate*` fields, and is gathered back into one `certificate` object here; it is `null` for a plain
548
+ HTTP listener and for an HTTPS one the server found none for.
549
+
550
+ ## Development
551
+
552
+ ```bash
553
+ npm install
554
+ npm test # compiles src and test into build/, then runs node --test
555
+ npm run build # compiles src into dist/ with its .d.ts, which is what npm publishes
556
+ ```
557
+
558
+ The suite runs against a fake euclid gateway (`test/fake-gateway.ts`) that authenticates requests
559
+ with the same rules `Core::HttpActionServer::Authenticate` applies, so a client that signs one thing
560
+ and sends another fails there rather than in production. The SigV4 canonicalisation is additionally
561
+ pinned by AWS's own published test vectors, which is what makes this SDK, euclid's C++, euclid-jdk
562
+ and euclid-pdk agree rather than merely each agree with itself.
563
+
564
+ Behind that gateway sit two stand-ins that implement rather than stub the parts a client can get
565
+ wrong. `test/fake-storage.ts` assembles what it is sent: an upload's parts are joined in part order and
566
+ a download hands back the byte range asked for, so a client that numbers its parts wrongly, sizes them
567
+ inconsistently or reassembles them out of order fails there rather than by writing a corrupt object to
568
+ a real server. `test/fake-queues.ts` really leases messages out and either honours a long poll or
569
+ declines it, which is what makes "took a message twice" and "abandoned a poll the server was still
570
+ serving" visible to a test at all.
571
+
572
+ Tests are compiled rather than run through a loader or node's type stripping, so `npm test` behaves
573
+ the same on every supported node.
574
+
575
+ ### Releasing
576
+
577
+ A release says its version in three places, and `.github/workflows/publish.yml` refuses to publish
578
+ unless all three agree: the tag, `version` in `package.json` (which names the tarball), and `VERSION`
579
+ in `src/index.ts` (which is what an application asking this SDK its own version is told - checked
580
+ against the built `dist`, since that is what ships). So bump the two files, then tag and push:
581
+
582
+ ```bash
583
+ npm version 0.2.0 --no-git-tag-version # package.json
584
+ $EDITOR src/index.ts # VERSION
585
+ git commit -am "chore: release 0.2.0"
586
+ git tag -a v0.2.0 -m "euclid-ndk 0.2.0"
587
+ git push origin main v0.2.0
588
+ ```
589
+
590
+ The tag runs the tests again (the test workflow triggers on branches, so a tag push would otherwise
591
+ run nothing), checks the three versions, builds `dist`, packs the tarball and publishes it to npm with
592
+ a provenance attestation - a signed statement of which workflow, at which commit, built what was
593
+ published. `workflow_dispatch` does the same for whatever `main` says, which is what a version whose
594
+ tag predates this workflow needs; it checks the two files against each other but has no tag to
595
+ compare.
596
+
597
+ Publishing authenticates with an `NPM_TOKEN` secret. Once the package exists on npm, configuring a
598
+ trusted publisher for this repository and `publish.yml` replaces it: npm then mints a short-lived
599
+ token from the workflow's own OIDC identity, and the secret can be deleted.
600
+
601
+ ## Licence
602
+
603
+ Apache License 2.0.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Request signing and verification for euclid.
3
+ *
4
+ * Two schemes, one credential pair. {@link import("./sigv4.js")} is what euclid has always spoken;
5
+ * {@link import("./rfc9421.js")} is the standard scheme replacing it. Both are keyed by the access
6
+ * key ID and secret a login hands back, and {@link SigningScheme} is how a client picks between
7
+ * them.
8
+ *
9
+ * Verification is here as well as signing, because it is the only way to demonstrate that the two
10
+ * canonicalisations are the same one, and because a Node service fronting euclid needs to check the
11
+ * signatures it receives with the same rules the server applies.
12
+ */
13
+ export * as rfc9421 from "./rfc9421.js";
14
+ export { RFC9421, SIGV4, signingSchemeOf } from "./scheme.js";
15
+ export type { SigningScheme } from "./scheme.js";
16
+ export { SignableRequest } from "./signable.js";
17
+ export * as sigv4 from "./sigv4.js";
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,OAAO,MAAM,cAAc,CAAC;AACxC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9D,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,KAAK,MAAM,YAAY,CAAC"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Request signing and verification for euclid.
3
+ *
4
+ * Two schemes, one credential pair. {@link import("./sigv4.js")} is what euclid has always spoken;
5
+ * {@link import("./rfc9421.js")} is the standard scheme replacing it. Both are keyed by the access
6
+ * key ID and secret a login hands back, and {@link SigningScheme} is how a client picks between
7
+ * them.
8
+ *
9
+ * Verification is here as well as signing, because it is the only way to demonstrate that the two
10
+ * canonicalisations are the same one, and because a Node service fronting euclid needs to check the
11
+ * signatures it receives with the same rules the server applies.
12
+ */
13
+ export * as rfc9421 from "./rfc9421.js";
14
+ export { RFC9421, SIGV4, signingSchemeOf } from "./scheme.js";
15
+ export { SignableRequest } from "./signable.js";
16
+ export * as sigv4 from "./sigv4.js";
17
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,OAAO,MAAM,cAAc,CAAC;AACxC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9D,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,KAAK,MAAM,YAAY,CAAC"}