@loynazkovacs/theitemapp-backend-sdk 0.1.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/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @loynazkovacs/theitemapp-backend-sdk
2
+
3
+ Server-side SDK for **TheItemApp app backends** — the counterpart to the
4
+ frontend [`@loynazkovacs/theitemapp-platform-sdk`](../platform-sdk).
5
+
6
+ Every app with its own backend (chat, coding-agent, vanda, music, image-generator,
7
+ …) previously hand-rolled the same three things and let them drift:
8
+
9
+ 1. a `coreApiClient.ts` wrapping `/api/dynamic` CRUD,
10
+ 2. a registration / heartbeat / deregister loop in `index.ts`,
11
+ 3. an auth middleware proxying `/api/auth/me`.
12
+
13
+ This package is the single canonical implementation of all three.
14
+
15
+ > **Scope:** this SDK is for apps that run **their own backend**. Apps with no
16
+ > backend ship via the [`seed-server`](../../images/seed-server) base image
17
+ > instead and do not need this.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install @loynazkovacs/theitemapp-backend-sdk
23
+ ```
24
+
25
+ Requires Node ≥ 18 (uses the global `fetch`/`FormData`). Zero runtime dependencies.
26
+
27
+ ## Usage
28
+
29
+ ```ts
30
+ import {
31
+ CoreApiClient,
32
+ startAppRegistration,
33
+ createFastifyAuthPreHandler, // or createExpressAuthMiddleware
34
+ } from '@loynazkovacs/theitemapp-backend-sdk';
35
+ import manifest from './dbseed/manifest.json' assert { type: 'json' };
36
+
37
+ const coreUrl = process.env.CORE_API_URL ?? 'http://backend:3001';
38
+
39
+ // 1. Core API client — key gets filled in by registration below.
40
+ const coreApi = new CoreApiClient({ baseUrl: coreUrl, apiKey: null });
41
+
42
+ // 2. Registration lifecycle: retry-until-up, heartbeat, deregister-on-exit.
43
+ const registration = startAppRegistration({
44
+ coreUrl,
45
+ manifest,
46
+ selfUrl: process.env.SELF_URL ?? 'http://myapp:80',
47
+ registrationKey: process.env.APP_REGISTRATION_KEY,
48
+ onApiKey: (key) => coreApi.updateApiKey(key), // core rotates the key per register
49
+ });
50
+
51
+ // Wire core's reboot recovery kick into your own route:
52
+ app.post('/app/re-register', async () => {
53
+ registration.reRegister();
54
+ return { ok: true };
55
+ });
56
+
57
+ // 3. Auth: verify users against core on protected routes.
58
+ app.addHook('preHandler', createFastifyAuthPreHandler(coreUrl)); // sets request.user
59
+ ```
60
+
61
+ ### Framework-agnostic auth
62
+
63
+ If you're not on Express/Fastify, call the core directly:
64
+
65
+ ```ts
66
+ import { verifyUser, userInAnyGroup } from '@loynazkovacs/theitemapp-backend-sdk';
67
+
68
+ const user = await verifyUser(coreUrl, { cookie, authorization });
69
+ if (!user) return reply401();
70
+ ```
71
+
72
+ ## Exports
73
+
74
+ | Export | Purpose |
75
+ | --- | --- |
76
+ | `CoreApiClient`, `CoreApiError` | Typed CRUD over `/api/dynamic` (+ file upload, decrypt, bulk-upsert, atomic upsert). |
77
+ | `startAppRegistration` | Registration retry loop, API-key rotation capture, heartbeat, deregister, signal handlers. |
78
+ | `verifyUser`, `normalizeId`, `userInAnyGroup` | Framework-agnostic auth primitives. |
79
+ | `createExpressAuthMiddleware`, `createFastifyAuthPreHandler` | Drop-in middleware/preHandler. |
80
+
81
+ ## Not yet covered (roadmap)
82
+
83
+ - `aggregate()` — core exposes pivot aggregation through the dynamic list route
84
+ (`_agg`), which isn't a clean stable HTTP contract yet. Use `list()` with
85
+ query params for now.
86
+ - Seed/function HTTP serving — that belongs to apps without a backend, handled
87
+ by the `seed-server` image.
88
+
89
+ ## Publishing
90
+
91
+ Same flow as the frontend SDK:
92
+
93
+ ```bash
94
+ cd libs/backend-sdk
95
+ npm run build # tsc → dist/
96
+ npm publish # uses .npmrc registry + publishConfig (public)
97
+ ```
package/dist/auth.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * User authentication for app backends.
3
+ *
4
+ * App backends don't verify JWTs themselves — they forward the caller's
5
+ * session cookie / `Authorization` header to core's `GET /api/auth/me`, which
6
+ * is the single source of truth for identity and group membership. Every
7
+ * backend reimplemented this proxy (`authMiddleware.ts` / `auth.ts`); this is
8
+ * the canonical version plus thin Express and Fastify adapters.
9
+ */
10
+ export type AuthUser = {
11
+ _id: string;
12
+ email?: string;
13
+ groupIds: string[];
14
+ /** The full `/api/auth/me` payload, for fields beyond the common ones. */
15
+ raw: Record<string, unknown>;
16
+ };
17
+ export type Credentials = {
18
+ cookie?: string;
19
+ authorization?: string;
20
+ };
21
+ export type VerifyOptions = {
22
+ /** Timeout for the core round-trip in ms (default 5000). */
23
+ timeoutMs?: number;
24
+ };
25
+ /**
26
+ * Verify a caller against core. Returns the `AuthUser` on success, or `null`
27
+ * if no credentials were supplied or core rejected them. Throws only when core
28
+ * itself is unreachable (so callers can answer 503 vs 401 distinctly).
29
+ */
30
+ export declare function verifyUser(coreUrl: string, creds: Credentials, opts?: VerifyOptions): Promise<AuthUser | null>;
31
+ /**
32
+ * Normalize an id-ish value to a 24-hex string. Handles plain strings,
33
+ * `{ $oid }` extended-JSON, and Mongo `ObjectId`-like objects (anything with a
34
+ * sane `toString`). Returns `''` for null/undefined/unrecognized shapes.
35
+ *
36
+ * x-ref array fields on `items`-schema records come back as `ObjectId[]` from
37
+ * the driver even when typed `string[]` — always normalize before comparing
38
+ * against a `Set<string>` of group ids, or every check silently fails.
39
+ */
40
+ export declare function normalizeId(value: unknown): string;
41
+ type ReqWithHeaders = {
42
+ headers: {
43
+ cookie?: string;
44
+ authorization?: string;
45
+ };
46
+ };
47
+ interface ExpressResLike {
48
+ locals: Record<string, unknown>;
49
+ status(code: number): {
50
+ json(body: unknown): unknown;
51
+ };
52
+ }
53
+ type ExpressNext = (err?: unknown) => void;
54
+ /**
55
+ * Express middleware: verifies the caller and stores the `AuthUser` on
56
+ * `res.locals[key]` (default `user`). Responds 401 (no/invalid credentials)
57
+ * or 503 (core unreachable) itself.
58
+ */
59
+ export declare function createExpressAuthMiddleware(coreUrl: string, options?: {
60
+ localsKey?: string;
61
+ } & VerifyOptions): (req: ReqWithHeaders, res: ExpressResLike, next: ExpressNext) => Promise<void>;
62
+ interface FastifyReqLike extends ReqWithHeaders {
63
+ [k: string]: unknown;
64
+ }
65
+ interface FastifyReplyLike {
66
+ code(status: number): {
67
+ send(body: unknown): unknown;
68
+ };
69
+ }
70
+ /**
71
+ * Fastify preHandler: verifies the caller and assigns the `AuthUser` onto
72
+ * `request[key]` (default `user`). Responds 401/503 itself. Decorate your
73
+ * request (`app.decorateRequest('user', null)`) for typed access.
74
+ */
75
+ export declare function createFastifyAuthPreHandler(coreUrl: string, options?: {
76
+ requestKey?: string;
77
+ } & VerifyOptions): (request: FastifyReqLike, reply: FastifyReplyLike) => Promise<void>;
78
+ /** True if the user belongs to any of the given group ids. */
79
+ export declare function userInAnyGroup(user: Pick<AuthUser, 'groupIds'>, groupIds: Iterable<string>): boolean;
80
+ export {};
81
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +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,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,CA4B1B;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"}
package/dist/auth.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * User authentication for app backends.
3
+ *
4
+ * App backends don't verify JWTs themselves — they forward the caller's
5
+ * session cookie / `Authorization` header to core's `GET /api/auth/me`, which
6
+ * is the single source of truth for identity and group membership. Every
7
+ * backend reimplemented this proxy (`authMiddleware.ts` / `auth.ts`); this is
8
+ * the canonical version plus thin Express and Fastify adapters.
9
+ */
10
+ /**
11
+ * Verify a caller against core. Returns the `AuthUser` on success, or `null`
12
+ * if no credentials were supplied or core rejected them. Throws only when core
13
+ * itself is unreachable (so callers can answer 503 vs 401 distinctly).
14
+ */
15
+ export async function verifyUser(coreUrl, creds, opts = {}) {
16
+ if (!creds.cookie && !creds.authorization)
17
+ return null;
18
+ const meUrl = `${coreUrl.replace(/\/$/, '')}/api/auth/me`;
19
+ const headers = {};
20
+ if (creds.cookie)
21
+ headers.cookie = creds.cookie;
22
+ if (creds.authorization)
23
+ headers.authorization = creds.authorization;
24
+ const res = await fetch(meUrl, {
25
+ headers,
26
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 5_000),
27
+ });
28
+ if (!res.ok)
29
+ return null;
30
+ const data = (await res.json());
31
+ const userId = normalizeId(data._id);
32
+ if (!userId)
33
+ return null;
34
+ const groupIds = Array.isArray(data.groupIds)
35
+ ? data.groupIds.map((g) => normalizeId(g)).filter((g) => g.length > 0)
36
+ : [];
37
+ return {
38
+ _id: userId,
39
+ email: typeof data.email === 'string' ? data.email : undefined,
40
+ groupIds,
41
+ raw: data,
42
+ };
43
+ }
44
+ /**
45
+ * Normalize an id-ish value to a 24-hex string. Handles plain strings,
46
+ * `{ $oid }` extended-JSON, and Mongo `ObjectId`-like objects (anything with a
47
+ * sane `toString`). Returns `''` for null/undefined/unrecognized shapes.
48
+ *
49
+ * x-ref array fields on `items`-schema records come back as `ObjectId[]` from
50
+ * the driver even when typed `string[]` — always normalize before comparing
51
+ * against a `Set<string>` of group ids, or every check silently fails.
52
+ */
53
+ export function normalizeId(value) {
54
+ if (!value)
55
+ return '';
56
+ if (typeof value === 'string')
57
+ return value;
58
+ if (typeof value === 'object') {
59
+ const oid = value.$oid;
60
+ if (typeof oid === 'string')
61
+ return oid;
62
+ const str = String(value);
63
+ if (/^[a-f0-9]{24}$/i.test(str))
64
+ return str;
65
+ }
66
+ return '';
67
+ }
68
+ /**
69
+ * Express middleware: verifies the caller and stores the `AuthUser` on
70
+ * `res.locals[key]` (default `user`). Responds 401 (no/invalid credentials)
71
+ * or 503 (core unreachable) itself.
72
+ */
73
+ export function createExpressAuthMiddleware(coreUrl, options = {}) {
74
+ const key = options.localsKey ?? 'user';
75
+ return async (req, res, next) => {
76
+ try {
77
+ const user = await verifyUser(coreUrl, { cookie: req.headers.cookie, authorization: req.headers.authorization }, options);
78
+ if (!user) {
79
+ res.status(401).json({ error: 'Unauthorized' });
80
+ return;
81
+ }
82
+ res.locals[key] = user;
83
+ next();
84
+ }
85
+ catch {
86
+ res.status(503).json({ error: 'Auth service unavailable' });
87
+ }
88
+ };
89
+ }
90
+ /**
91
+ * Fastify preHandler: verifies the caller and assigns the `AuthUser` onto
92
+ * `request[key]` (default `user`). Responds 401/503 itself. Decorate your
93
+ * request (`app.decorateRequest('user', null)`) for typed access.
94
+ */
95
+ export function createFastifyAuthPreHandler(coreUrl, options = {}) {
96
+ const key = options.requestKey ?? 'user';
97
+ return async (request, reply) => {
98
+ try {
99
+ const user = await verifyUser(coreUrl, { cookie: request.headers.cookie, authorization: request.headers.authorization }, options);
100
+ if (!user) {
101
+ reply.code(401).send({ error: 'Unauthorized' });
102
+ return;
103
+ }
104
+ request[key] = user;
105
+ }
106
+ catch {
107
+ reply.code(503).send({ error: 'Auth service unavailable' });
108
+ }
109
+ };
110
+ }
111
+ /** True if the user belongs to any of the given group ids. */
112
+ export function userInAnyGroup(user, groupIds) {
113
+ const set = groupIds instanceof Set ? groupIds : new Set(groupIds);
114
+ return user.groupIds.some((id) => set.has(id));
115
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * CoreApiClient — a thin, typed client over core's `/api/dynamic/<collection>`
3
+ * CRUD surface (plus the file-upload and decrypt endpoints).
4
+ *
5
+ * Every app backend on the platform previously hand-rolled its own copy of
6
+ * this class (`coreApiClient.ts`), each drifting in features and error
7
+ * handling. This is the canonical, general implementation.
8
+ *
9
+ * Auth: writes use the app's functional `x-api-key` (auto-provisioned by core
10
+ * on registration — see `startAppRegistration`), and by default set
11
+ * `x-theitemapp-skip-webhooks` so high-frequency system writes don't fan out
12
+ * through the webhook pipeline. Use `updateAsUser` to attribute a write to an
13
+ * end user via their JWT instead.
14
+ */
15
+ export declare class CoreApiError extends Error {
16
+ readonly status: number;
17
+ readonly body: string;
18
+ constructor(method: string, target: string, status: number, body: string);
19
+ }
20
+ export type CoreApiConfig = {
21
+ /** Core API base URL, e.g. `http://backend:3001`. Trailing slash is trimmed. */
22
+ baseUrl: string;
23
+ /** Functional API key. May be `null` until registration provisions one. */
24
+ apiKey?: string | null;
25
+ /**
26
+ * Send `x-theitemapp-skip-webhooks: 1` on writes (default `true`). Set
27
+ * `false` if your writes should trigger webhook/automation fan-out.
28
+ */
29
+ skipWebhooks?: boolean;
30
+ };
31
+ export type BulkUpsertResult = {
32
+ upsertedCount: number;
33
+ modifiedCount: number;
34
+ errors: Array<{
35
+ index: number;
36
+ error: string;
37
+ }>;
38
+ };
39
+ export type UploadFileOptions = {
40
+ filename: string;
41
+ mimeType: string;
42
+ kind?: 'file' | 'image';
43
+ visibility?: 'private' | 'groups' | 'everyone';
44
+ groupIds?: string[];
45
+ title?: string;
46
+ };
47
+ export declare class CoreApiClient {
48
+ private readonly baseUrl;
49
+ private readonly skipWebhooks;
50
+ private readonly headers;
51
+ constructor(config: CoreApiConfig);
52
+ /** Replace the functional API key (e.g. after core rotates it on re-registration). */
53
+ updateApiKey(apiKey: string): void;
54
+ /** Current functional API key (empty string if none set yet). */
55
+ getApiKey(): string;
56
+ /** Whether a non-empty API key is present — gate writes on this post-registration. */
57
+ isReady(): boolean;
58
+ /** Headers minus `Content-Type` — for requests with no JSON body (DELETE) or multipart. */
59
+ private headersWithoutContentType;
60
+ /**
61
+ * List documents. `query` maps to core's dynamic list params, e.g.
62
+ * `{ _l: '50', _s: '-createdAt', someField: 'x' }`. Defaults to `_l=500`.
63
+ */
64
+ list<T = Record<string, unknown>>(collection: string, query?: Record<string, string>): Promise<T[]>;
65
+ /**
66
+ * Fetch one document by id. Returns `null` for a genuine 404; throws
67
+ * `CoreApiError` for transient failures (401/403/429/5xx) so callers can
68
+ * distinguish "doesn't exist" from "couldn't read it".
69
+ */
70
+ get<T = Record<string, unknown>>(collection: string, id: string): Promise<T | null>;
71
+ /**
72
+ * Find a single document by an indexed field. Returns `null` for no match;
73
+ * throws `CoreApiError` on transient failure (never masks a read error as
74
+ * "not found").
75
+ */
76
+ findBy<T = Record<string, unknown>>(collection: string, field: string, value: string): Promise<T | null>;
77
+ /** Create a document. Throws `CoreApiError` on failure. */
78
+ create<T = Record<string, unknown>>(collection: string, body: Record<string, unknown>): Promise<T>;
79
+ /** Update a document by id (PUT). Throws `CoreApiError` on failure. */
80
+ update<T = Record<string, unknown>>(collection: string, id: string, body: Record<string, unknown>): Promise<T>;
81
+ /**
82
+ * Update a document AS the given user, by sending their short-lived JWT
83
+ * (`Authorization: Bearer`) instead of the functional `x-api-key`. Core then
84
+ * attributes the change to that human in the audit log and
85
+ * `_meta.lastModifiedByUserId`. The functional key is deliberately omitted —
86
+ * core treats an `x-api-key` match as the system actor, which would override
87
+ * the asserted user identity.
88
+ */
89
+ updateAsUser<T = Record<string, unknown>>(collection: string, id: string, body: Record<string, unknown>, userJwt: string): Promise<T>;
90
+ /** Soft-delete a document by id. Returns `true` on success or 404 (already gone). */
91
+ delete(collection: string, id: string): Promise<boolean>;
92
+ /**
93
+ * Atomically upsert a document by a unique field, via core's server-side
94
+ * `POST /api/dynamic/:collection/upsert` (single Mongo `findOneAndUpdate`).
95
+ * Falls back to a findBy + update/create pair on older cores that 404 the
96
+ * upsert route.
97
+ */
98
+ upsert<T = Record<string, unknown>>(collection: string, matchField: string, matchValue: string, body: Record<string, unknown>): Promise<T | null>;
99
+ private upsertLegacy;
100
+ /**
101
+ * Bulk-upsert up to 500 documents, matched by a composite key (typically
102
+ * `['_id']`). Returns core's `{ upsertedCount, modifiedCount, errors }`
103
+ * summary. Throws `CoreApiError` on transport/protocol failure.
104
+ */
105
+ bulkUpsert(collection: string, matchOn: string[], documents: Record<string, unknown>[]): Promise<BulkUpsertResult>;
106
+ /**
107
+ * Upload a file to core's file API (`POST /api/files/uploadDirect`). Returns
108
+ * the created file document (`{ _id, originalName }`) for referencing.
109
+ * Accepts a `Uint8Array` (a Node `Buffer` is a `Uint8Array`, so it works too).
110
+ */
111
+ uploadFile(data: Uint8Array, opts: UploadFileOptions): Promise<{
112
+ _id: string;
113
+ originalName: string;
114
+ }>;
115
+ /**
116
+ * Decrypt `x-encrypted` fields for a record via core's internal decrypt
117
+ * endpoint. Returns a map of field name → plaintext, or `null` on failure.
118
+ */
119
+ decrypt(collection: string, id: string): Promise<Record<string, string | null> | null>;
120
+ }
121
+ //# sourceMappingURL=core-api-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core-api-client.d.ts","sourceRoot":"","sources":["../src/core-api-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,237 @@
1
+ /**
2
+ * CoreApiClient — a thin, typed client over core's `/api/dynamic/<collection>`
3
+ * CRUD surface (plus the file-upload and decrypt endpoints).
4
+ *
5
+ * Every app backend on the platform previously hand-rolled its own copy of
6
+ * this class (`coreApiClient.ts`), each drifting in features and error
7
+ * handling. This is the canonical, general implementation.
8
+ *
9
+ * Auth: writes use the app's functional `x-api-key` (auto-provisioned by core
10
+ * on registration — see `startAppRegistration`), and by default set
11
+ * `x-theitemapp-skip-webhooks` so high-frequency system writes don't fan out
12
+ * through the webhook pipeline. Use `updateAsUser` to attribute a write to an
13
+ * end user via their JWT instead.
14
+ */
15
+ export class CoreApiError extends Error {
16
+ status;
17
+ body;
18
+ constructor(method, target, status, body) {
19
+ super(`[coreApi] ${method} ${target} failed: ${status} — ${body.slice(0, 300)}`);
20
+ this.name = 'CoreApiError';
21
+ this.status = status;
22
+ this.body = body;
23
+ }
24
+ }
25
+ export class CoreApiClient {
26
+ baseUrl;
27
+ skipWebhooks;
28
+ headers;
29
+ constructor(config) {
30
+ this.baseUrl = config.baseUrl.replace(/\/$/, '');
31
+ this.skipWebhooks = config.skipWebhooks ?? true;
32
+ this.headers = {
33
+ 'Content-Type': 'application/json',
34
+ ...(config.apiKey ? { 'x-api-key': config.apiKey } : {}),
35
+ ...(this.skipWebhooks ? { 'x-theitemapp-skip-webhooks': '1' } : {}),
36
+ };
37
+ }
38
+ /** Replace the functional API key (e.g. after core rotates it on re-registration). */
39
+ updateApiKey(apiKey) {
40
+ this.headers['x-api-key'] = apiKey;
41
+ }
42
+ /** Current functional API key (empty string if none set yet). */
43
+ getApiKey() {
44
+ return this.headers['x-api-key'] ?? '';
45
+ }
46
+ /** Whether a non-empty API key is present — gate writes on this post-registration. */
47
+ isReady() {
48
+ return (this.headers['x-api-key']?.length ?? 0) > 0;
49
+ }
50
+ /** Headers minus `Content-Type` — for requests with no JSON body (DELETE) or multipart. */
51
+ headersWithoutContentType() {
52
+ const { 'Content-Type': _ct, ...rest } = this.headers;
53
+ return rest;
54
+ }
55
+ /**
56
+ * List documents. `query` maps to core's dynamic list params, e.g.
57
+ * `{ _l: '50', _s: '-createdAt', someField: 'x' }`. Defaults to `_l=500`.
58
+ */
59
+ async list(collection, query) {
60
+ const params = new URLSearchParams({ _l: '500', ...query });
61
+ const url = `${this.baseUrl}/api/dynamic/${collection}?${params.toString()}`;
62
+ const res = await fetch(url, { headers: this.headers });
63
+ if (!res.ok)
64
+ throw new CoreApiError('LIST', collection, res.status, await safeText(res));
65
+ const data = await res.json();
66
+ return Array.isArray(data) ? data : [];
67
+ }
68
+ /**
69
+ * Fetch one document by id. Returns `null` for a genuine 404; throws
70
+ * `CoreApiError` for transient failures (401/403/429/5xx) so callers can
71
+ * distinguish "doesn't exist" from "couldn't read it".
72
+ */
73
+ async get(collection, id) {
74
+ const url = `${this.baseUrl}/api/dynamic/${collection}/${encodeURIComponent(id)}`;
75
+ const res = await fetch(url, { headers: this.headers });
76
+ if (res.ok)
77
+ return (await res.json());
78
+ if (res.status === 404)
79
+ return null;
80
+ throw new CoreApiError('GET', `${collection}/${id}`, res.status, await safeText(res));
81
+ }
82
+ /**
83
+ * Find a single document by an indexed field. Returns `null` for no match;
84
+ * throws `CoreApiError` on transient failure (never masks a read error as
85
+ * "not found").
86
+ */
87
+ async findBy(collection, field, value) {
88
+ const params = new URLSearchParams({ [field]: value, _l: '1' });
89
+ const url = `${this.baseUrl}/api/dynamic/${collection}?${params.toString()}`;
90
+ const res = await fetch(url, { headers: this.headers });
91
+ if (!res.ok)
92
+ throw new CoreApiError('FINDBY', collection, res.status, await safeText(res));
93
+ const data = (await res.json());
94
+ return Array.isArray(data) ? (data[0] ?? null) : null;
95
+ }
96
+ /** Create a document. Throws `CoreApiError` on failure. */
97
+ async create(collection, body) {
98
+ const url = `${this.baseUrl}/api/dynamic/${collection}`;
99
+ const res = await fetch(url, { method: 'POST', headers: this.headers, body: JSON.stringify(body) });
100
+ if (!res.ok)
101
+ throw new CoreApiError('CREATE', collection, res.status, await safeText(res));
102
+ return (await res.json());
103
+ }
104
+ /** Update a document by id (PUT). Throws `CoreApiError` on failure. */
105
+ async update(collection, id, body) {
106
+ const url = `${this.baseUrl}/api/dynamic/${collection}/${encodeURIComponent(id)}`;
107
+ const res = await fetch(url, { method: 'PUT', headers: this.headers, body: JSON.stringify(body) });
108
+ if (!res.ok)
109
+ throw new CoreApiError('UPDATE', `${collection}/${id}`, res.status, await safeText(res));
110
+ return (await res.json());
111
+ }
112
+ /**
113
+ * Update a document AS the given user, by sending their short-lived JWT
114
+ * (`Authorization: Bearer`) instead of the functional `x-api-key`. Core then
115
+ * attributes the change to that human in the audit log and
116
+ * `_meta.lastModifiedByUserId`. The functional key is deliberately omitted —
117
+ * core treats an `x-api-key` match as the system actor, which would override
118
+ * the asserted user identity.
119
+ */
120
+ async updateAsUser(collection, id, body, userJwt) {
121
+ const url = `${this.baseUrl}/api/dynamic/${collection}/${encodeURIComponent(id)}`;
122
+ const res = await fetch(url, {
123
+ method: 'PUT',
124
+ headers: {
125
+ 'Content-Type': 'application/json',
126
+ Authorization: `Bearer ${userJwt}`,
127
+ ...(this.skipWebhooks ? { 'x-theitemapp-skip-webhooks': '1' } : {}),
128
+ },
129
+ body: JSON.stringify(body),
130
+ });
131
+ if (!res.ok) {
132
+ throw new CoreApiError('UPDATE_AS_USER', `${collection}/${id}`, res.status, await safeText(res));
133
+ }
134
+ return (await res.json());
135
+ }
136
+ /** Soft-delete a document by id. Returns `true` on success or 404 (already gone). */
137
+ async delete(collection, id) {
138
+ const url = `${this.baseUrl}/api/dynamic/${collection}/${encodeURIComponent(id)}`;
139
+ // No Content-Type / body — Fastify rejects an empty JSON body
140
+ // (FST_ERR_CTP_EMPTY_JSON_BODY).
141
+ const res = await fetch(url, { method: 'DELETE', headers: this.headersWithoutContentType() });
142
+ if (res.ok || res.status === 404)
143
+ return true;
144
+ throw new CoreApiError('DELETE', `${collection}/${id}`, res.status, await safeText(res));
145
+ }
146
+ /**
147
+ * Atomically upsert a document by a unique field, via core's server-side
148
+ * `POST /api/dynamic/:collection/upsert` (single Mongo `findOneAndUpdate`).
149
+ * Falls back to a findBy + update/create pair on older cores that 404 the
150
+ * upsert route.
151
+ */
152
+ async upsert(collection, matchField, matchValue, body) {
153
+ const fullDoc = { ...body, [matchField]: matchValue };
154
+ const url = `${this.baseUrl}/api/dynamic/${collection}/upsert`;
155
+ const res = await fetch(url, {
156
+ method: 'POST',
157
+ headers: this.headers,
158
+ body: JSON.stringify({ matchOn: [matchField], document: fullDoc }),
159
+ });
160
+ if (res.ok) {
161
+ const json = (await res.json());
162
+ return json.doc ?? null;
163
+ }
164
+ if (res.status === 404)
165
+ return this.upsertLegacy(collection, matchField, matchValue, body);
166
+ throw new CoreApiError('UPSERT', collection, res.status, await safeText(res));
167
+ }
168
+ async upsertLegacy(collection, matchField, matchValue, body) {
169
+ const existing = await this.findBy(collection, matchField, matchValue);
170
+ if (existing && typeof existing._id === 'string') {
171
+ return this.update(collection, existing._id, body);
172
+ }
173
+ return this.create(collection, { ...body, [matchField]: matchValue });
174
+ }
175
+ /**
176
+ * Bulk-upsert up to 500 documents, matched by a composite key (typically
177
+ * `['_id']`). Returns core's `{ upsertedCount, modifiedCount, errors }`
178
+ * summary. Throws `CoreApiError` on transport/protocol failure.
179
+ */
180
+ async bulkUpsert(collection, matchOn, documents) {
181
+ if (documents.length === 0)
182
+ return { upsertedCount: 0, modifiedCount: 0, errors: [] };
183
+ const url = `${this.baseUrl}/api/dynamic/${collection}/bulk-upsert`;
184
+ const res = await fetch(url, {
185
+ method: 'POST',
186
+ headers: this.headers,
187
+ body: JSON.stringify({ matchOn, documents }),
188
+ });
189
+ if (!res.ok)
190
+ throw new CoreApiError('BULK_UPSERT', collection, res.status, await safeText(res));
191
+ return (await res.json());
192
+ }
193
+ /**
194
+ * Upload a file to core's file API (`POST /api/files/uploadDirect`). Returns
195
+ * the created file document (`{ _id, originalName }`) for referencing.
196
+ * Accepts a `Uint8Array` (a Node `Buffer` is a `Uint8Array`, so it works too).
197
+ */
198
+ async uploadFile(data, opts) {
199
+ const form = new FormData();
200
+ // Metadata fields MUST precede the file blob — @fastify/multipart stops
201
+ // reading fields once the file stream is consumed.
202
+ form.append('kind', opts.kind ?? 'file');
203
+ form.append('visibility', opts.visibility ?? 'everyone');
204
+ if (opts.title)
205
+ form.append('title', opts.title);
206
+ if (opts.groupIds?.length)
207
+ form.append('groupIds', opts.groupIds.join(','));
208
+ // `data` is a valid BlobPart at runtime; the cast sidesteps the DOM lib's
209
+ // SharedArrayBuffer-vs-ArrayBuffer strictness on `Uint8Array`.
210
+ form.append('file', new Blob([data], { type: opts.mimeType }), opts.filename);
211
+ // Let FormData set the multipart Content-Type (with boundary).
212
+ const url = `${this.baseUrl}/api/files/uploadDirect`;
213
+ const res = await fetch(url, { method: 'POST', headers: this.headersWithoutContentType(), body: form });
214
+ if (!res.ok)
215
+ throw new CoreApiError('UPLOAD_FILE', opts.filename, res.status, await safeText(res));
216
+ return (await res.json());
217
+ }
218
+ /**
219
+ * Decrypt `x-encrypted` fields for a record via core's internal decrypt
220
+ * endpoint. Returns a map of field name → plaintext, or `null` on failure.
221
+ */
222
+ async decrypt(collection, id) {
223
+ const url = `${this.baseUrl}/api/internal/decrypt/${collection}/${encodeURIComponent(id)}`;
224
+ const res = await fetch(url, { headers: this.headers });
225
+ if (!res.ok)
226
+ return null;
227
+ return (await res.json());
228
+ }
229
+ }
230
+ async function safeText(res) {
231
+ try {
232
+ return await res.text();
233
+ }
234
+ catch {
235
+ return '';
236
+ }
237
+ }
@@ -0,0 +1,4 @@
1
+ export * from './core-api-client.js';
2
+ export * from './registration.js';
3
+ export * from './auth.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,WAAW,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from './core-api-client.js';
2
+ export * from './registration.js';
3
+ export * from './auth.js';
@@ -0,0 +1,73 @@
1
+ /**
2
+ * App registration lifecycle.
3
+ *
4
+ * Every app backend must register itself with core so core inserts an `apps`
5
+ * catalog row, runs the app's seeds, and provisions the app's functional API
6
+ * key. The shape of that handshake — POST `/api/apps/register`, capture the
7
+ * rotated `apiKey`, retry until core is up, re-register on core's reboot,
8
+ * heartbeat every 5 minutes, deregister on shutdown — was copy-pasted into
9
+ * every backend's `index.ts`. This is the canonical implementation.
10
+ *
11
+ * The helper is HTTP-framework-agnostic: it owns the timers and process
12
+ * signal handlers, and hands you back a `reRegister()` you wire into your own
13
+ * `POST /app/re-register` route.
14
+ */
15
+ export type AppManifest = {
16
+ appKey: string;
17
+ appVersion?: string;
18
+ [k: string]: unknown;
19
+ };
20
+ export type RegistrationLogger = {
21
+ info: (msg: string) => void;
22
+ warn: (msg: string) => void;
23
+ error: (msg: string) => void;
24
+ };
25
+ export type RegistrationOptions = {
26
+ /** Core API base URL, e.g. `http://backend:3001`. */
27
+ coreUrl: string;
28
+ /** The app's seed manifest (must contain `appKey`). */
29
+ manifest: AppManifest;
30
+ /** How core reaches this container, e.g. `http://myapp:80`. */
31
+ selfUrl: string;
32
+ /**
33
+ * Shared registration secret (`X-Registration-Key` header). Required by
34
+ * cores that gate app registration; omit for open local stacks.
35
+ */
36
+ registrationKey?: string | null;
37
+ /**
38
+ * Called whenever core returns a freshly provisioned/rotated functional
39
+ * API key. Wire this to `coreApi.updateApiKey`.
40
+ */
41
+ onApiKey?: (apiKey: string) => void;
42
+ /** Retry attempts while core is still booting (default 30). */
43
+ maxRetries?: number;
44
+ /** Delay between retries in ms (default 5000). */
45
+ retryIntervalMs?: number;
46
+ /** Heartbeat interval in ms (default 5 min). Set `0` to disable. */
47
+ heartbeatMs?: number;
48
+ /** Install SIGTERM/SIGINT handlers that deregister + exit (default true). */
49
+ installSignalHandlers?: boolean;
50
+ /** Logger (default `console`). */
51
+ logger?: RegistrationLogger;
52
+ };
53
+ export type RegistrationHandle = {
54
+ /** Run one registration attempt now. Resolves `true` on success. */
55
+ register: () => Promise<boolean>;
56
+ /**
57
+ * Fire-and-forget re-registration — wire into your `POST /app/re-register`
58
+ * route. Returns immediately; the registration runs in the background.
59
+ */
60
+ reRegister: () => void;
61
+ /** Deregister from core (best-effort). */
62
+ deregister: () => Promise<void>;
63
+ /** Stop the heartbeat timer (does not deregister). */
64
+ stop: () => void;
65
+ };
66
+ /**
67
+ * Start the registration lifecycle. Kicks off a background retry loop
68
+ * immediately (does not block your server's `listen`), starts the heartbeat,
69
+ * and — unless disabled — installs shutdown handlers. Returns a handle you use
70
+ * to wire the `/app/re-register` route and to control shutdown.
71
+ */
72
+ export declare function startAppRegistration(opts: RegistrationOptions): RegistrationHandle;
73
+ //# sourceMappingURL=registration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registration.d.ts","sourceRoot":"","sources":["../src/registration.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,uDAAuD;IACvD,QAAQ,EAAE,WAAW,CAAC;IACtB,+DAA+D;IAC/D,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kDAAkD;IAClD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,kCAAkC;IAClC,MAAM,CAAC,EAAE,kBAAkB,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,oEAAoE;IACpE,QAAQ,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACjC;;;OAGG;IACH,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,0CAA0C;IAC1C,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,sDAAsD;IACtD,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB,CAAC;AAIF;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,mBAAmB,GAAG,kBAAkB,CAmGlF"}
@@ -0,0 +1,119 @@
1
+ /**
2
+ * App registration lifecycle.
3
+ *
4
+ * Every app backend must register itself with core so core inserts an `apps`
5
+ * catalog row, runs the app's seeds, and provisions the app's functional API
6
+ * key. The shape of that handshake — POST `/api/apps/register`, capture the
7
+ * rotated `apiKey`, retry until core is up, re-register on core's reboot,
8
+ * heartbeat every 5 minutes, deregister on shutdown — was copy-pasted into
9
+ * every backend's `index.ts`. This is the canonical implementation.
10
+ *
11
+ * The helper is HTTP-framework-agnostic: it owns the timers and process
12
+ * signal handlers, and hands you back a `reRegister()` you wire into your own
13
+ * `POST /app/re-register` route.
14
+ */
15
+ const REGISTER_PATH = '/api/apps/register';
16
+ /**
17
+ * Start the registration lifecycle. Kicks off a background retry loop
18
+ * immediately (does not block your server's `listen`), starts the heartbeat,
19
+ * and — unless disabled — installs shutdown handlers. Returns a handle you use
20
+ * to wire the `/app/re-register` route and to control shutdown.
21
+ */
22
+ export function startAppRegistration(opts) {
23
+ const coreUrl = opts.coreUrl.replace(/\/$/, '');
24
+ const appKey = opts.manifest.appKey;
25
+ const log = opts.logger ?? console;
26
+ const maxRetries = opts.maxRetries ?? 30;
27
+ const retryIntervalMs = opts.retryIntervalMs ?? 5_000;
28
+ const heartbeatMs = opts.heartbeatMs ?? 5 * 60 * 1000;
29
+ const installSignals = opts.installSignalHandlers ?? true;
30
+ // Last issued key prefix — sent back so core rotates the SAME key rather
31
+ // than minting a new one on every re-registration.
32
+ let currentKeyPrefix = null;
33
+ let heartbeatTimer = null;
34
+ const register = async () => {
35
+ const body = { manifest: opts.manifest, baseUrl: opts.selfUrl };
36
+ if (currentKeyPrefix)
37
+ body.apiKeyPrefix = currentKeyPrefix;
38
+ try {
39
+ const res = await fetch(`${coreUrl}${REGISTER_PATH}`, {
40
+ method: 'POST',
41
+ headers: {
42
+ 'Content-Type': 'application/json',
43
+ ...(opts.registrationKey ? { 'X-Registration-Key': opts.registrationKey } : {}),
44
+ },
45
+ body: JSON.stringify(body),
46
+ });
47
+ if (!res.ok) {
48
+ log.warn(`[${appKey}] Registration failed: ${res.status} ${res.statusText}`);
49
+ return false;
50
+ }
51
+ const data = (await res.json());
52
+ if (data.apiKey) {
53
+ currentKeyPrefix = String(data.apiKey).slice(0, 8);
54
+ opts.onApiKey?.(data.apiKey);
55
+ }
56
+ log.info(`[${appKey}] Registered with core (engine: ${data.engineVersion ?? 'unknown'})`);
57
+ return true;
58
+ }
59
+ catch {
60
+ // Core not reachable yet — caller's retry loop handles it.
61
+ return false;
62
+ }
63
+ };
64
+ const retryUntilRegistered = async () => {
65
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
66
+ if (await register())
67
+ return;
68
+ log.info(`[${appKey}] Core not ready, retrying (${attempt}/${maxRetries})...`);
69
+ await delay(retryIntervalMs);
70
+ }
71
+ log.error(`[${appKey}] Failed to register with core after ${maxRetries} attempts`);
72
+ };
73
+ const deregister = async () => {
74
+ try {
75
+ await fetch(`${coreUrl}${REGISTER_PATH}/${appKey}`, { method: 'DELETE' });
76
+ log.info(`[${appKey}] Deregistered from core`);
77
+ }
78
+ catch {
79
+ // Best effort — core might already be down.
80
+ }
81
+ };
82
+ // Kick registration in the background — never block startup.
83
+ void retryUntilRegistered();
84
+ // Heartbeat: re-register periodically. Registration is idempotent on core's
85
+ // side and refreshes the rotated API key, recovering from a missed
86
+ // `/app/re-register` kick or a core reconcile-loop catalog drop.
87
+ if (heartbeatMs > 0) {
88
+ heartbeatTimer = setInterval(() => {
89
+ void register();
90
+ }, heartbeatMs);
91
+ heartbeatTimer.unref?.();
92
+ }
93
+ const stop = () => {
94
+ if (heartbeatTimer) {
95
+ clearInterval(heartbeatTimer);
96
+ heartbeatTimer = null;
97
+ }
98
+ };
99
+ if (installSignals) {
100
+ const shutdown = async () => {
101
+ stop();
102
+ await deregister();
103
+ process.exit(0);
104
+ };
105
+ process.on('SIGTERM', () => void shutdown());
106
+ process.on('SIGINT', () => void shutdown());
107
+ }
108
+ return {
109
+ register,
110
+ reRegister: () => {
111
+ void register();
112
+ },
113
+ deregister,
114
+ stop,
115
+ };
116
+ }
117
+ function delay(ms) {
118
+ return new Promise((resolve) => setTimeout(resolve, ms));
119
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@loynazkovacs/theitemapp-backend-sdk",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Server-side SDK for TheItemApp app backends: core API client, registration lifecycle, and auth verification.",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/LoynazKovacs/TheItemApp.git",
12
+ "directory": "libs/backend-sdk"
13
+ },
14
+ "publishConfig": {
15
+ "registry": "https://registry.npmjs.org",
16
+ "access": "public"
17
+ },
18
+ "sideEffects": false,
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "README.md"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.json",
34
+ "prepublishOnly": "npm run build"
35
+ },
36
+ "devDependencies": {
37
+ "typescript": "~5.9.2"
38
+ }
39
+ }