@loynazkovacs/theitemapp-backend-sdk 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@loynazkovacs/theitemapp-backend-sdk`. Pre-1.0, minor
4
+ versions may add features; `0.x` caret ranges (`^0.3.0`) do **not** auto-bump
5
+ across minors, so consumers upgrade deliberately.
6
+
7
+ ## 0.4.0
8
+
9
+ Reliability & safety layers, so the most demanding clients (chat, coding-agent)
10
+ can adopt the SDK as thin adapters with no behaviour loss. All opt-in, default off.
11
+
12
+ ### Added
13
+ - **`retry`** config — automatic retry on configured statuses (default `[429]`),
14
+ honouring `Retry-After` (header or "retry in N seconds" body), with jitter and
15
+ a per-wait cap. Protects fire-and-forget writes from rate-limit drops.
16
+ - **`validateXrefs`** config — load a collection's `items` schema (cached,
17
+ single-flight) and validate x-ref fields hold valid ObjectIds before each
18
+ write. `onInvalidXref: 'warn' | 'throw'`.
19
+ - **`logger`** config — route retry/validation/decrypt warnings (default `console`).
20
+ - **`CoreApiError.collection` / `.method` / `.target`** — structured fields
21
+ alongside `.status` / `.body`.
22
+ - **`upsert(..., { skipIfUnchanged })`** — skip the write when the existing row
23
+ already deep-equals the body (no spurious change-stream events).
24
+ - Exported utilities: **`extractXrefFields`**, **`checkXrefFields`**, **`deepEqual`**.
25
+ - `asUser(...)` now propagates the parent's `retry` / `validateXrefs` / `logger`.
26
+
27
+ ### Docs
28
+ - Comprehensive README (quick start, all three pillars, full API reference,
29
+ reliability/safety layers, adoption recipe) + this changelog.
30
+
31
+ ## 0.3.0
32
+
33
+ Closed the gaps surfaced migrating 14 backends.
34
+
35
+ ### Added
36
+ - `count()`, `bulkCreate()` (`/bulk`), `hardDeleteByFilter()`.
37
+ - `upsertOn(matchOn[])` — composite-key atomic upsert → `{ ok, created, doc }`.
38
+ - `get(id, { populate })`, `downloadFile()`, `decrypt(..., { throwOnError })`.
39
+ - Per-call `WriteOptions { skipWebhooks, headers }` on create/update/upsertOn/bulkCreate.
40
+ - **`asUser(creds, { keepApiKey })`** — scoped client that forwards the end
41
+ user's `Authorization`/`Cookie`.
42
+ - **`verifyJwtLocal(token, secret)`** — offline HS256 verification (zero-dep).
43
+
44
+ ## 0.2.0
45
+
46
+ ### Fixed
47
+ - `verifyUser` reads `id` from `/api/auth/me` (the JWT `sub`), not `_id` — it
48
+ previously returned `null` for every authenticated caller. `AuthUser` gained
49
+ `username`.
50
+
51
+ ## 0.1.0
52
+
53
+ Initial release: `CoreApiClient` (typed `/api/dynamic` CRUD + file upload +
54
+ decrypt + upsert/bulk-upsert), `startAppRegistration` (register/retry/heartbeat/
55
+ deregister + provisioned-key capture), `verifyUser` + Express/Fastify auth
56
+ adapters. Zero runtime dependencies.
package/README.md CHANGED
@@ -5,18 +5,42 @@
5
5
  Server-side SDK for **TheItemApp app backends** — the counterpart to the
6
6
  frontend [`@loynazkovacs/theitemapp-platform-sdk`](../platform-sdk).
7
7
 
8
- Every app with its own backend (chat, coding-agent, vanda, music, image-generator,
9
- …) previously hand-rolled the same three things and let them drift:
8
+ Every app with its own backend used to hand-roll the same three things and let
9
+ them drift:
10
10
 
11
- 1. a `coreApiClient.ts` wrapping `/api/dynamic` CRUD,
12
- 2. a registration / heartbeat / deregister loop in `index.ts`,
13
- 3. an auth middleware proxying `/api/auth/me`.
11
+ 1. a **core API client** wrapping `/api/dynamic` CRUD,
12
+ 2. a **registration / heartbeat / deregister** loop in `index.ts`,
13
+ 3. an **auth middleware** proxying `/api/auth/me`.
14
14
 
15
- This package is the single canonical implementation of all three.
15
+ This package is the single, canonical, typed implementation of all three.
16
16
 
17
- > **Scope:** this SDK is for apps that run **their own backend**. Apps with no
18
- > backend ship via the [`seed-server`](../../images/seed-server) base image
19
- > instead and do not need this.
17
+ > **Scope.** This SDK is for apps that run their **own backend**. Apps with no
18
+ > backend ship via the [`seed-server`](../../images/seed-server) base image and
19
+ > do not need it.
20
+
21
+ - **Zero runtime dependencies.** Node ≥ 18 (uses the global `fetch`/`FormData`/`crypto`).
22
+ - **Everything opt-in & back-compat.** New reliability/safety layers default off.
23
+
24
+ ---
25
+
26
+ ## Table of contents
27
+
28
+ - [Install](#install)
29
+ - [Quick start](#quick-start)
30
+ - [Pillar 1 — Registration lifecycle](#pillar-1--registration-lifecycle)
31
+ - [Pillar 2 — CoreApiClient](#pillar-2--coreapiclient)
32
+ - [Reads](#reads) · [Writes](#writes) · [Upserts](#upserts) · [Files](#files) · [Decrypt](#decrypt)
33
+ - [Acting as an end user (`asUser`)](#acting-as-an-end-user-asuser)
34
+ - [Reliability: automatic retry](#reliability-automatic-retry)
35
+ - [Safety: x-ref validation](#safety-x-ref-validation)
36
+ - [Error handling](#error-handling)
37
+ - [Full config reference](#full-config-reference)
38
+ - [Pillar 3 — Auth](#pillar-3--auth)
39
+ - [Adoption recipe (existing backends)](#adoption-recipe-existing-backends)
40
+ - [Versioning & publishing](#versioning--publishing)
41
+ - [API index](#api-index)
42
+
43
+ ---
20
44
 
21
45
  ## Install
22
46
 
@@ -24,76 +48,400 @@ This package is the single canonical implementation of all three.
24
48
  npm install @loynazkovacs/theitemapp-backend-sdk
25
49
  ```
26
50
 
27
- Requires Node ≥ 18 (uses the global `fetch`/`FormData`). Zero runtime dependencies.
51
+ Node ≥ 18, ESM (`"type": "module"`).
52
+
53
+ ---
28
54
 
29
- ## Usage
55
+ ## Quick start
56
+
57
+ A typical Fastify app backend wires all three pillars at startup:
30
58
 
31
59
  ```ts
60
+ import Fastify from 'fastify';
32
61
  import {
33
62
  CoreApiClient,
34
63
  startAppRegistration,
35
- createFastifyAuthPreHandler, // or createExpressAuthMiddleware
64
+ createFastifyAuthPreHandler,
36
65
  } from '@loynazkovacs/theitemapp-backend-sdk';
37
66
  import manifest from './dbseed/manifest.json' assert { type: 'json' };
38
67
 
68
+ const app = Fastify();
39
69
  const coreUrl = process.env.CORE_API_URL ?? 'http://backend:3001';
40
70
 
41
- // 1. Core API client — key gets filled in by registration below.
42
- const coreApi = new CoreApiClient({ baseUrl: coreUrl, apiKey: null });
71
+ // 1) Core API client — key gets filled in by registration below.
72
+ const coreApi = new CoreApiClient({
73
+ baseUrl: coreUrl,
74
+ apiKey: null,
75
+ retry: { maxAttempts: 5 }, // optional: survive 429 bursts
76
+ });
77
+
78
+ // 2) Registration: retry-until-up, capture the rotated key, heartbeat, deregister.
79
+ let registration;
80
+ app.post('/app/re-register', async () => { registration?.reRegister(); return { ok: true }; });
43
81
 
44
- // 2. Registration lifecycle: retry-until-up, heartbeat, deregister-on-exit.
45
- const registration = startAppRegistration({
82
+ await app.listen({ host: '0.0.0.0', port: 3000 });
83
+
84
+ registration = startAppRegistration({
46
85
  coreUrl,
47
86
  manifest,
48
87
  selfUrl: process.env.SELF_URL ?? 'http://myapp:80',
49
88
  registrationKey: process.env.APP_REGISTRATION_KEY,
89
+ installSignalHandlers: false, // we run our own shutdown below
50
90
  onApiKey: (key) => coreApi.updateApiKey(key), // core rotates the key per register
51
91
  });
52
92
 
53
- // Wire core's reboot recovery kick into your own route:
54
- app.post('/app/re-register', async () => {
55
- registration.reRegister();
56
- return { ok: true };
93
+ process.on('SIGTERM', async () => {
94
+ registration?.stop();
95
+ await registration?.deregister();
96
+ await app.close();
97
+ process.exit(0);
98
+ });
99
+
100
+ // 3) Auth on protected routes — verifies the caller against core.
101
+ app.get('/api/things', { preHandler: createFastifyAuthPreHandler(coreUrl) }, async () => {
102
+ return coreApi.list('things');
103
+ });
104
+ ```
105
+
106
+ ---
107
+
108
+ ## Pillar 1 — Registration lifecycle
109
+
110
+ `startAppRegistration(opts)` owns the full handshake every backend needs:
111
+ register-with-retry (core can take minutes to boot), capture the
112
+ **auto-provisioned & rotated** functional API key, a 5-minute heartbeat,
113
+ re-register on core's reboot, and deregister on shutdown.
114
+
115
+ ```ts
116
+ const registration = startAppRegistration({
117
+ coreUrl, // core base URL
118
+ manifest, // your dbseed manifest (must contain appKey)
119
+ selfUrl, // how core reaches this container, e.g. http://myapp:80
120
+ registrationKey, // optional shared secret (X-Registration-Key)
121
+ onApiKey: (key) => coreApi.updateApiKey(key),
122
+ heartbeatMs: 5 * 60_000, // optional (default 5 min; 0 disables)
123
+ maxRetries: 30, // optional (default 30)
124
+ retryIntervalMs: 5_000, // optional (default 5s)
125
+ installSignalHandlers: true, // default true; set false to run your own shutdown
126
+ logger: app.log, // optional { info, warn, error }
127
+ });
128
+ ```
129
+
130
+ | Returns | |
131
+ |---|---|
132
+ | `register()` | run one registration attempt now → `Promise<boolean>` |
133
+ | `reRegister()` | fire-and-forget; wire into your `POST /app/re-register` route |
134
+ | `deregister()` | best-effort `DELETE` from core's catalog |
135
+ | `stop()` | stop the heartbeat timer |
136
+
137
+ **Custom shutdown.** If your app must clean up on exit (stop a collector, close
138
+ a DB, end sessions), pass `installSignalHandlers: false` and call
139
+ `registration.stop()` + `await registration.deregister()` inside your own
140
+ `SIGTERM`/`SIGINT` handler.
141
+
142
+ **Provisioned key.** Core returns a fresh functional `apiKey` on each register
143
+ (rotated, prefix-stable) **only if the app seeds a functional user**. `onApiKey`
144
+ fires whenever a key is issued — use it to update your `CoreApiClient`. Apps
145
+ that seed no functional user simply never receive one (that's fine).
146
+
147
+ ---
148
+
149
+ ## Pillar 2 — CoreApiClient
150
+
151
+ A typed wrapper over `/api/dynamic/<collection>` plus the file and decrypt
152
+ endpoints. Construct once; update the key when registration provides it.
153
+
154
+ ```ts
155
+ const coreApi = new CoreApiClient({ baseUrl, apiKey: null });
156
+ coreApi.updateApiKey(key); // after registration
157
+ coreApi.isReady(); // true once a non-empty key is set — gate writes on this
158
+ ```
159
+
160
+ ### Reads
161
+
162
+ ```ts
163
+ await coreApi.list('apps'); // _l=500 by default
164
+ await coreApi.list('apps', { _l: '50', _s: '-createdAt' }); // override params
165
+ await coreApi.get('apps', id); // null on 404, throws otherwise
166
+ await coreApi.get('apps', id, { populate: false }); // keep x-refs as id strings (?populate=0)
167
+ await coreApi.findBy('apps', 'key', 'system'); // first match by indexed field, or null
168
+ await coreApi.count('apps', { active: 'true' }); // GET .../count → number
169
+ ```
170
+
171
+ > **`populate`.** Core returns single x-ref fields as **full objects** by
172
+ > default. If your code compares x-ref ids as strings (e.g. diffing rows),
173
+ > read with `{ populate: false }` so they stay as 24-hex strings.
174
+
175
+ ### Writes
176
+
177
+ ```ts
178
+ await coreApi.create('apps', { name: 'X' });
179
+ await coreApi.update('apps', id, { name: 'Y' }); // PUT (partial merge)
180
+ await coreApi.delete('apps', id); // soft-delete → true (also on 404)
181
+ await coreApi.bulkCreate('events', docs); // ≤500 → { ok, created, failed, results }
182
+ await coreApi.hardDeleteByFilter('events', { stale: true }); // api-key only → { ok, deletedCount }
183
+ ```
184
+
185
+ Per-call `WriteOptions` on `create`/`update`/`upsertOn`/`bulkCreate`:
186
+
187
+ ```ts
188
+ await coreApi.create('audit', row, { skipWebhooks: false }); // let this write fan out
189
+ await coreApi.update('x', id, body, { headers: { 'x-foo': '1' } });
190
+ ```
191
+
192
+ ### Upserts
193
+
194
+ ```ts
195
+ // single field → returns the doc (or null); falls back to find+update/create on old cores
196
+ await coreApi.upsert('apps', 'key', 'system', body);
197
+
198
+ // skip the write if nothing changed (no spurious change-stream events)
199
+ await coreApi.upsert('host', 'name', 'localhost', body, { skipIfUnchanged: true });
200
+
201
+ // composite key → full result so you can branch on created vs updated
202
+ const { created, doc } = await coreApi.upsertOn('links', ['srcId', 'dstId'], body);
203
+
204
+ // bulk upsert ≤500 by composite key (usually ['_id'])
205
+ await coreApi.bulkUpsert('rows', ['_id'], docs); // → { upsertedCount, modifiedCount, errors }
206
+ ```
207
+
208
+ ### Files
209
+
210
+ ```ts
211
+ const { _id } = await coreApi.uploadFile(bytes, { // Uint8Array (a Buffer works)
212
+ filename: 'cover.png', mimeType: 'image/png', kind: 'image',
213
+ });
214
+ const file = await coreApi.downloadFile(_id); // { data, contentType, filename } | null
215
+ ```
216
+
217
+ ### Decrypt
218
+
219
+ ```ts
220
+ await coreApi.decrypt('connections', id); // null on failure (logs a warn)
221
+ await coreApi.decrypt('connections', id, { throwOnError: true });
222
+ ```
223
+
224
+ ### Acting as an end user (`asUser`)
225
+
226
+ By default the client authenticates with the **functional `x-api-key`** (the
227
+ app/system actor). To make core attribute a call to the **human user** — and
228
+ apply *their* RBAC — derive a scoped client that forwards their session:
229
+
230
+ ```ts
231
+ // attribute the write to the user (drops the functional key):
232
+ await coreApi.asUser({ authorization: req.headers.authorization }).create('notes', body);
233
+
234
+ // forward the cookie session, keep the functional key too (app identity + user):
235
+ await coreApi.asUser({ cookie: req.headers.cookie }, { keepApiKey: true })
236
+ .uploadFile(bytes, { filename, mimeType });
237
+
238
+ // works for every method — reads, writes, upload, download, decrypt:
239
+ await coreApi.asUser({ jwt }).list('my_private_things');
240
+ ```
241
+
242
+ The scoped client **inherits** the parent's `retry` / `validateXrefs` / `logger`
243
+ config.
244
+
245
+ ### Reliability: automatic retry
246
+
247
+ Off by default. When enabled, requests retry on configured statuses (default
248
+ `[429]`), honouring `Retry-After` (header **or** a "retry in N seconds" body),
249
+ with jitter and a per-wait cap. Essential for fire-and-forget writes
250
+ (transcript/audit/usage) that core may rate-limit.
251
+
252
+ ```ts
253
+ new CoreApiClient({
254
+ baseUrl, apiKey,
255
+ retry: {
256
+ maxAttempts: 5, // default 5 when `retry` is set
257
+ retryOn: [429], // default [429]; add 503 etc. if you want
258
+ honorRetryAfter: true, // default true
259
+ maxDelayMs: 20_000, // default 20s cap per wait
260
+ },
261
+ });
262
+ ```
263
+
264
+ ### Safety: x-ref validation
265
+
266
+ Off by default. When enabled, the client loads each collection's `items`
267
+ schema (cached, single-flight), and before every write checks that x-ref
268
+ fields hold valid 24-hex ObjectIds — catching a class of bugs where a name or
269
+ slug is passed where an id belongs.
270
+
271
+ ```ts
272
+ new CoreApiClient({
273
+ baseUrl, apiKey,
274
+ validateXrefs: true,
275
+ onInvalidXref: 'warn', // 'warn' (default, logs) or 'throw' (refuses the write)
57
276
  });
277
+ ```
278
+
279
+ The building blocks are also exported for custom use:
280
+ `extractXrefFields(schema)`, `checkXrefFields(body, map)`.
281
+
282
+ ### Error handling
283
+
284
+ Reads return `null` for genuine "not found" (`get`/`findBy` on 404). Everything
285
+ else **throws `CoreApiError`** on a non-2xx response:
58
286
 
59
- // 3. Auth: verify users against core on protected routes.
60
- app.addHook('preHandler', createFastifyAuthPreHandler(coreUrl)); // sets request.user
287
+ ```ts
288
+ import { CoreApiError } from '@loynazkovacs/theitemapp-backend-sdk';
289
+
290
+ try {
291
+ await coreApi.update('apps', id, body);
292
+ } catch (err) {
293
+ if (err instanceof CoreApiError) {
294
+ err.status; // 409
295
+ err.method; // 'UPDATE'
296
+ err.collection; // 'apps'
297
+ err.target; // 'apps/<id>'
298
+ err.body; // raw response body
299
+ }
300
+ }
61
301
  ```
62
302
 
63
- ### Framework-agnostic auth
303
+ Prefer a null-returns contract? Wrap the call:
304
+ `const doc = await coreApi.create(c, b).catch(() => null);`
64
305
 
65
- If you're not on Express/Fastify, call the core directly:
306
+ ### Full config reference
66
307
 
67
308
  ```ts
68
- import { verifyUser, userInAnyGroup } from '@loynazkovacs/theitemapp-backend-sdk';
309
+ new CoreApiClient({
310
+ baseUrl: 'http://backend:3001', // required
311
+ apiKey: null, // functional key (set later via updateApiKey)
312
+ skipWebhooks: true, // default true (x-theitemapp-skip-webhooks on writes)
313
+ retry: { /* RetryConfig */ }, // default off
314
+ validateXrefs: false, // default off
315
+ onInvalidXref: 'warn', // 'warn' | 'throw'
316
+ logger: console, // { info?, warn?, error? } — default console
317
+ });
318
+ ```
69
319
 
70
- const user = await verifyUser(coreUrl, { cookie, authorization });
71
- if (!user) return reply401();
320
+ ---
321
+
322
+ ## Pillar 3 — Auth
323
+
324
+ App backends don't verify JWTs themselves by default — they forward the
325
+ caller's `Authorization`/`Cookie` to core's `GET /api/auth/me`, the single
326
+ source of truth for identity and group membership.
327
+
328
+ ```ts
329
+ import {
330
+ verifyUser, userInAnyGroup, normalizeId,
331
+ createExpressAuthMiddleware, createFastifyAuthPreHandler,
332
+ verifyJwtLocal,
333
+ } from '@loynazkovacs/theitemapp-backend-sdk';
72
334
  ```
73
335
 
74
- ## Exports
336
+ ### Verify against core (default)
75
337
 
76
- | Export | Purpose |
77
- | --- | --- |
78
- | `CoreApiClient`, `CoreApiError` | Typed CRUD over `/api/dynamic` (+ file upload, decrypt, bulk-upsert, atomic upsert). |
79
- | `startAppRegistration` | Registration retry loop, API-key rotation capture, heartbeat, deregister, signal handlers. |
80
- | `verifyUser`, `normalizeId`, `userInAnyGroup` | Framework-agnostic auth primitives. |
81
- | `createExpressAuthMiddleware`, `createFastifyAuthPreHandler` | Drop-in middleware/preHandler. |
338
+ ```ts
339
+ const user = await verifyUser(coreUrl, {
340
+ cookie: req.headers.cookie,
341
+ authorization: req.headers.authorization,
342
+ });
343
+ // { _id, username, email?, groupIds, raw } | null
344
+ // null = no credentials or core rejected them; throws only if core is unreachable.
345
+ ```
346
+
347
+ > Core's `/api/auth/me` returns the user id as **`id`** (the JWT `sub`), not
348
+ > `_id` — `verifyUser` handles this; `user._id` is always populated.
349
+
350
+ Group gating:
351
+
352
+ ```ts
353
+ if (!userInAnyGroup(user, adminGroupIds)) return reply.code(403).send();
354
+ ```
355
+
356
+ ### Drop-in middleware
357
+
358
+ ```ts
359
+ // Fastify — assigns request.user
360
+ app.addHook('preHandler', createFastifyAuthPreHandler(coreUrl));
361
+
362
+ // Express — assigns res.locals.user
363
+ app.use(createExpressAuthMiddleware(coreUrl));
364
+ ```
365
+
366
+ Both respond `401` (no/invalid creds) or `503` (core unreachable) themselves.
82
367
 
83
- ## Not yet covered (roadmap)
368
+ ### Offline verification
369
+
370
+ For backends that hold core's `JWT_SECRET` and want to skip the network round
371
+ trip (e.g. high-frequency idempotency checks):
372
+
373
+ ```ts
374
+ const payload = verifyJwtLocal(token, process.env.JWT_SECRET!);
375
+ // → decoded HS256 payload | null (null = malformed / bad signature / expired / not HS256)
376
+ ```
84
377
 
85
- - `aggregate()` — core exposes pivot aggregation through the dynamic list route
86
- (`_agg`), which isn't a clean stable HTTP contract yet. Use `list()` with
87
- query params for now.
88
- - Seed/function HTTP serving — that belongs to apps without a backend, handled
89
- by the `seed-server` image.
378
+ ---
90
379
 
91
- ## Publishing
380
+ ## Adoption recipe (existing backends)
92
381
 
93
- Same flow as the frontend SDK:
382
+ Migrate a hand-rolled backend onto the SDK with **zero behaviour change** by
383
+ making each existing module a thin shell over the SDK:
384
+
385
+ 1. Add the dependency (`^0.4.0`). If the Dockerfile uses `npm ci`, regenerate
386
+ `package-lock.json`.
387
+ 2. **Registration** → replace the register/retry/heartbeat/deregister block
388
+ with `startAppRegistration` (`installSignalHandlers: false` if you have a
389
+ custom shutdown).
390
+ 3. **coreApiClient.ts** → keep your exported class/signatures, but back it with
391
+ an internal `CoreApiClient` and delegate. Preserve your conventions via
392
+ config: `populate=false` reads, `retry` for a 429 layer, `validateXrefs` for
393
+ id checks, `asUser` for user-attributed writes. Re-export your `DynRow`/error
394
+ types so call-sites don't change.
395
+ 4. **Auth** → swap your `/api/auth/me` proxy internals for `verifyUser` (or
396
+ `verifyJwtLocal` if you verify offline). Keep your middleware's exported shape.
397
+ 5. Typecheck, deploy, verify (registration log, auth 401/200, a real read/write),
398
+ then commit + push.
399
+
400
+ The `system` backend is the reference implementation; see the
401
+ `backend-sdk-adoption` agent skill for the full per-app checklist.
402
+
403
+ ---
404
+
405
+ ## Versioning & publishing
406
+
407
+ Published from `main` by the `Build Images` workflow (`publish-backend-sdk`
408
+ job) on any change under `libs/backend-sdk/**`. The job is idempotent (skips if
409
+ the version already exists) and **fails loudly** if a publish genuinely fails.
94
410
 
95
411
  ```bash
96
412
  cd libs/backend-sdk
97
413
  npm run build # tsc → dist/
98
414
  npm publish # uses .npmrc registry + publishConfig (public)
99
415
  ```
416
+
417
+ See [CHANGELOG.md](./CHANGELOG.md) for version history. Caret ranges on `0.x`
418
+ do **not** auto-bump across minors (`^0.3.0` ≠ `0.4.0`), so pinned apps upgrade
419
+ deliberately.
420
+
421
+ ---
422
+
423
+ ## API index
424
+
425
+ **Core client:** `CoreApiClient` · `CoreApiError` ·
426
+ `list` `get` `findBy` `count` `create` `bulkCreate` `update` `updateAsUser`
427
+ `delete` `hardDeleteByFilter` `upsert` `upsertOn` `bulkUpsert` `uploadFile`
428
+ `downloadFile` `decrypt` · `asUser` · `updateApiKey` `getApiKey` `isReady`
429
+ `loadXrefFields`
430
+
431
+ **Utilities:** `extractXrefFields` · `checkXrefFields` · `deepEqual`
432
+
433
+ **Registration:** `startAppRegistration` (`RegistrationHandle`, `AppManifest`)
434
+
435
+ **Auth:** `verifyUser` · `verifyJwtLocal` · `userInAnyGroup` · `normalizeId` ·
436
+ `createExpressAuthMiddleware` · `createFastifyAuthPreHandler`
437
+
438
+ **Types:** `CoreApiConfig` `RetryConfig` `SdkLogger` `WriteOptions` `GetOptions`
439
+ `UserCredentials` `AuthUser` `JwtPayload` `BulkCreateResult` `UpsertResult`
440
+ `BulkUpsertResult` `UploadFileOptions` `DownloadedFile` `XrefFieldMap`
441
+
442
+ ## Not covered (by design)
443
+
444
+ - `aggregate()` — core's pivot aggregation rides the dynamic list route
445
+ (`_agg`), not a stable HTTP contract yet. Use `list()` with params.
446
+ - Seed/function HTTP serving — that's the `seed-server` image's job (apps
447
+ without a backend).
package/dist/auth.d.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * session cookie / `Authorization` header to core's `GET /api/auth/me`, which
6
6
  * is the single source of truth for identity and group membership. Every
7
7
  * backend reimplemented this proxy (`authMiddleware.ts` / `auth.ts`); this is
8
- * the canonical version plus thin Express and Fastify adapters.
8
+ * the canonical version plus thin Express and Fastify adapters, and an offline
9
+ * HS256 `verifyJwtLocal` for backends that hold core's JWT secret.
9
10
  */
10
11
  export type AuthUser = {
11
12
  _id: string;
@@ -78,5 +79,19 @@ export declare function createFastifyAuthPreHandler(coreUrl: string, options?: {
78
79
  } & VerifyOptions): (request: FastifyReqLike, reply: FastifyReplyLike) => Promise<void>;
79
80
  /** True if the user belongs to any of the given group ids. */
80
81
  export declare function userInAnyGroup(user: Pick<AuthUser, 'groupIds'>, groupIds: Iterable<string>): boolean;
82
+ export type JwtPayload = Record<string, unknown> & {
83
+ sub?: string;
84
+ username?: string;
85
+ groupIds?: string[];
86
+ exp?: number;
87
+ iat?: number;
88
+ };
89
+ /**
90
+ * Verify an **HS256** JWT locally against a shared `secret` (no network call).
91
+ * Returns the decoded payload, or `null` if the token is malformed, the
92
+ * signature is invalid, the algorithm isn't HS256, or it has expired. The
93
+ * caller supplies the secret matching core's `JWT_SECRET`.
94
+ */
95
+ export declare function verifyJwtLocal(token: string, secret: string): JwtPayload | null;
81
96
  export {};
82
97
  //# sourceMappingURL=auth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,WAAW,EAClB,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAsC1B;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAUlD;AASD,KAAK,cAAc,GAAG;IAAE,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAE/E,UAAU,cAAc;IACtB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;KAAE,CAAC;CACxD;AACD,KAAK,WAAW,GAAG,CAAC,GAAG,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;AAE3C;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,aAAkB,IAGtC,KAAK,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,KAAG,OAAO,CAAC,IAAI,CAAC,CAiB1F;AAED,UAAU,cAAe,SAAQ,cAAc;IAC7C,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AACD,UAAU,gBAAgB;IACxB,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;KAAE,CAAC;CACxD;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,aAAkB,IAGvC,SAAS,cAAc,EAAE,OAAO,gBAAgB,KAAG,OAAO,CAAC,IAAI,CAAC,CAgB/E;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,UAAU,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,OAAO,CAGpG"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,WAAW,EAClB,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAsC1B;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAUlD;AASD,KAAK,cAAc,GAAG;IAAE,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAE/E,UAAU,cAAc;IACtB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;KAAE,CAAC;CACxD;AACD,KAAK,WAAW,GAAG,CAAC,GAAG,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;AAE3C;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,aAAkB,IAGtC,KAAK,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,KAAG,OAAO,CAAC,IAAI,CAAC,CAiB1F;AAED,UAAU,cAAe,SAAQ,cAAc;IAC7C,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AACD,UAAU,gBAAgB;IACxB,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;KAAE,CAAC;CACxD;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,aAAkB,IAGvC,SAAS,cAAc,EAAE,OAAO,gBAAgB,KAAG,OAAO,CAAC,IAAI,CAAC,CAgB/E;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,UAAU,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,OAAO,CAGpG;AAUD,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IACjD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAyB/E"}
package/dist/auth.js CHANGED
@@ -5,8 +5,10 @@
5
5
  * session cookie / `Authorization` header to core's `GET /api/auth/me`, which
6
6
  * is the single source of truth for identity and group membership. Every
7
7
  * backend reimplemented this proxy (`authMiddleware.ts` / `auth.ts`); this is
8
- * the canonical version plus thin Express and Fastify adapters.
8
+ * the canonical version plus thin Express and Fastify adapters, and an offline
9
+ * HS256 `verifyJwtLocal` for backends that hold core's JWT secret.
9
10
  */
11
+ import { createHmac, timingSafeEqual } from 'node:crypto';
10
12
  /**
11
13
  * Verify a caller against core. Returns the `AuthUser` on success, or `null`
12
14
  * if no credentials were supplied or core rejected them. Throws only when core
@@ -121,3 +123,45 @@ export function userInAnyGroup(user, groupIds) {
121
123
  const set = groupIds instanceof Set ? groupIds : new Set(groupIds);
122
124
  return user.groupIds.some((id) => set.has(id));
123
125
  }
126
+ /**
127
+ * Verify an **HS256** JWT locally against a shared `secret` (no network call).
128
+ * Returns the decoded payload, or `null` if the token is malformed, the
129
+ * signature is invalid, the algorithm isn't HS256, or it has expired. The
130
+ * caller supplies the secret matching core's `JWT_SECRET`.
131
+ */
132
+ export function verifyJwtLocal(token, secret) {
133
+ if (!token || !secret)
134
+ return null;
135
+ const parts = token.split('.');
136
+ if (parts.length !== 3)
137
+ return null;
138
+ const [headerB64, payloadB64, sigB64] = parts;
139
+ let header;
140
+ let payload;
141
+ try {
142
+ header = JSON.parse(base64UrlDecode(headerB64));
143
+ payload = JSON.parse(base64UrlDecode(payloadB64));
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ if (header.alg !== 'HS256')
149
+ return null;
150
+ const expected = base64ToUrl(createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest('base64'));
151
+ const a = new TextEncoder().encode(expected);
152
+ const b = new TextEncoder().encode(sigB64);
153
+ if (a.length !== b.length || !timingSafeEqual(a, b))
154
+ return null;
155
+ if (typeof payload.exp === 'number' && Date.now() / 1000 >= payload.exp)
156
+ return null;
157
+ return payload;
158
+ }
159
+ function base64UrlDecode(s) {
160
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '=');
161
+ const bin = atob(b64); // DOM lib: base64 → latin1 binary string
162
+ const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
163
+ return new TextDecoder().decode(bytes); // reinterpret as UTF-8
164
+ }
165
+ function base64ToUrl(b64) {
166
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
167
+ }