@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 +97 -0
- package/dist/auth.d.ts +81 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +115 -0
- package/dist/core-api-client.d.ts +121 -0
- package/dist/core-api-client.d.ts.map +1 -0
- package/dist/core-api-client.js +237 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/registration.d.ts +73 -0
- package/dist/registration.d.ts.map +1 -0
- package/dist/registration.js +119 -0
- package/package.json +39 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
}
|