@loynazkovacs/theitemapp-backend-sdk 0.3.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 +56 -0
- package/README.md +383 -54
- package/dist/core-api-client.d.ts +125 -56
- package/dist/core-api-client.d.ts.map +1 -1
- package/dist/core-api-client.js +276 -87
- package/package.json +3 -2
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
|
|
9
|
-
|
|
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
|
|
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
|
|
18
|
-
> backend ship via the [`seed-server`](../../images/seed-server) base image
|
|
19
|
-
>
|
|
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,95 +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
|
-
|
|
51
|
+
Node ≥ 18, ESM (`"type": "module"`).
|
|
52
|
+
|
|
53
|
+
---
|
|
28
54
|
|
|
29
|
-
##
|
|
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,
|
|
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
|
|
42
|
-
const coreApi = new CoreApiClient({
|
|
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
|
+
});
|
|
43
77
|
|
|
44
|
-
// 2
|
|
45
|
-
|
|
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 }; });
|
|
81
|
+
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
registration
|
|
56
|
-
|
|
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
|
|
58
283
|
|
|
59
|
-
|
|
60
|
-
|
|
284
|
+
Reads return `null` for genuine "not found" (`get`/`findBy` on 404). Everything
|
|
285
|
+
else **throws `CoreApiError`** on a non-2xx response:
|
|
286
|
+
|
|
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
|
-
|
|
303
|
+
Prefer a null-returns contract? Wrap the call:
|
|
304
|
+
`const doc = await coreApi.create(c, b).catch(() => null);`
|
|
64
305
|
|
|
65
|
-
|
|
306
|
+
### Full config reference
|
|
66
307
|
|
|
67
308
|
```ts
|
|
68
|
-
|
|
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
|
+
```
|
|
319
|
+
|
|
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.
|
|
69
327
|
|
|
70
|
-
|
|
71
|
-
|
|
328
|
+
```ts
|
|
329
|
+
import {
|
|
330
|
+
verifyUser, userInAnyGroup, normalizeId,
|
|
331
|
+
createExpressAuthMiddleware, createFastifyAuthPreHandler,
|
|
332
|
+
verifyJwtLocal,
|
|
333
|
+
} from '@loynazkovacs/theitemapp-backend-sdk';
|
|
72
334
|
```
|
|
73
335
|
|
|
74
|
-
|
|
336
|
+
### Verify against core (default)
|
|
75
337
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
```
|
|
83
346
|
|
|
84
|
-
|
|
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.
|
|
85
349
|
|
|
86
|
-
|
|
87
|
-
- `create`/`update`/`upsertOn`/`bulkCreate` take an optional `WriteOptions` (`{ skipWebhooks?, headers? }`) for per-call control.
|
|
88
|
-
- `upsertOn(c, matchOn[], doc)` — composite-key atomic upsert returning `{ ok, created, doc }`.
|
|
89
|
-
- `decrypt(c, id, { throwOnError: true })` — throw instead of returning `null`.
|
|
350
|
+
Group gating:
|
|
90
351
|
|
|
91
|
-
|
|
352
|
+
```ts
|
|
353
|
+
if (!userInAnyGroup(user, adminGroupIds)) return reply.code(403).send();
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### Drop-in middleware
|
|
92
357
|
|
|
93
358
|
```ts
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
97
367
|
|
|
98
|
-
|
|
99
|
-
|
|
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)
|
|
100
376
|
```
|
|
101
377
|
|
|
102
|
-
|
|
378
|
+
---
|
|
103
379
|
|
|
104
|
-
|
|
105
|
-
(`_agg`), which isn't a clean stable HTTP contract yet. Use `list()` with
|
|
106
|
-
query params for now.
|
|
107
|
-
- Seed/function HTTP serving — that belongs to apps without a backend, handled
|
|
108
|
-
by the `seed-server` image.
|
|
380
|
+
## Adoption recipe (existing backends)
|
|
109
381
|
|
|
110
|
-
|
|
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:
|
|
111
384
|
|
|
112
|
-
|
|
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.
|
|
113
410
|
|
|
114
411
|
```bash
|
|
115
412
|
cd libs/backend-sdk
|
|
116
413
|
npm run build # tsc → dist/
|
|
117
414
|
npm publish # uses .npmrc registry + publishConfig (public)
|
|
118
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).
|