@loynazkovacs/theitemapp-backend-sdk 0.2.0 → 0.3.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 CHANGED
@@ -75,11 +75,30 @@ if (!user) return reply401();
75
75
 
76
76
  | Export | Purpose |
77
77
  | --- | --- |
78
- | `CoreApiClient`, `CoreApiError` | Typed CRUD over `/api/dynamic` (+ file upload, decrypt, bulk-upsert, atomic upsert). |
78
+ | `CoreApiClient`, `CoreApiError` | Typed CRUD over `/api/dynamic`: `list`/`get`/`findBy`/`count`/`create`/`bulkCreate`/`update`/`updateAsUser`/`delete`/`hardDeleteByFilter`/`upsert`/`upsertOn`/`bulkUpsert`/`uploadFile`/`downloadFile`/`decrypt`. |
79
+ | `client.asUser(creds, opts?)` | Scoped client that acts AS an end user — forwards their `Authorization`/`Cookie` (optionally alongside the functional key). Full method surface. |
79
80
  | `startAppRegistration` | Registration retry loop, API-key rotation capture, heartbeat, deregister, signal handlers. |
80
- | `verifyUser`, `normalizeId`, `userInAnyGroup` | Framework-agnostic auth primitives. |
81
+ | `verifyUser`, `normalizeId`, `userInAnyGroup`, `verifyJwtLocal` | Framework-agnostic auth primitives (+ offline HS256 verification). |
81
82
  | `createExpressAuthMiddleware`, `createFastifyAuthPreHandler` | Drop-in middleware/preHandler. |
82
83
 
84
+ ### Read/write controls
85
+
86
+ - `get(c, id, { populate: false })` — keep x-ref fields as id strings (`?populate=0`).
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`.
90
+
91
+ ### Acting as an end user
92
+
93
+ ```ts
94
+ // forward the caller's session so core attributes the write to them:
95
+ await coreApi.asUser({ authorization: req.headers.authorization, cookie: req.headers.cookie })
96
+ .create('notes', { text: 'hi' });
97
+
98
+ // keep the functional key too (app identity + user session):
99
+ await coreApi.asUser({ jwt }, { keepApiKey: true }).uploadFile(bytes, { filename, mimeType });
100
+ ```
101
+
83
102
  ## Not yet covered (roadmap)
84
103
 
85
104
  - `aggregate()` — core exposes pivot aggregation through the dynamic list route
package/dist/auth.d.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * session cookie / `Authorization` header to core's `GET /api/auth/me`, which
6
6
  * is the single source of truth for identity and group membership. Every
7
7
  * backend reimplemented this proxy (`authMiddleware.ts` / `auth.ts`); this is
8
- * the canonical version plus thin Express and Fastify adapters.
8
+ * the canonical version plus thin Express and Fastify adapters, and an offline
9
+ * HS256 `verifyJwtLocal` for backends that hold core's JWT secret.
9
10
  */
10
11
  export type AuthUser = {
11
12
  _id: string;
@@ -78,5 +79,19 @@ export declare function createFastifyAuthPreHandler(coreUrl: string, options?: {
78
79
  } & VerifyOptions): (request: FastifyReqLike, reply: FastifyReplyLike) => Promise<void>;
79
80
  /** True if the user belongs to any of the given group ids. */
80
81
  export declare function userInAnyGroup(user: Pick<AuthUser, 'groupIds'>, groupIds: Iterable<string>): boolean;
82
+ export type JwtPayload = Record<string, unknown> & {
83
+ sub?: string;
84
+ username?: string;
85
+ groupIds?: string[];
86
+ exp?: number;
87
+ iat?: number;
88
+ };
89
+ /**
90
+ * Verify an **HS256** JWT locally against a shared `secret` (no network call).
91
+ * Returns the decoded payload, or `null` if the token is malformed, the
92
+ * signature is invalid, the algorithm isn't HS256, or it has expired. The
93
+ * caller supplies the secret matching core's `JWT_SECRET`.
94
+ */
95
+ export declare function verifyJwtLocal(token: string, secret: string): JwtPayload | null;
81
96
  export {};
82
97
  //# sourceMappingURL=auth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,WAAW,EAClB,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAsC1B;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAUlD;AASD,KAAK,cAAc,GAAG;IAAE,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAE/E,UAAU,cAAc;IACtB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;KAAE,CAAC;CACxD;AACD,KAAK,WAAW,GAAG,CAAC,GAAG,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;AAE3C;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,aAAkB,IAGtC,KAAK,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,KAAG,OAAO,CAAC,IAAI,CAAC,CAiB1F;AAED,UAAU,cAAe,SAAQ,cAAc;IAC7C,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AACD,UAAU,gBAAgB;IACxB,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;KAAE,CAAC;CACxD;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,aAAkB,IAGvC,SAAS,cAAc,EAAE,OAAO,gBAAgB,KAAG,OAAO,CAAC,IAAI,CAAC,CAgB/E;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,UAAU,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,OAAO,CAGpG"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,WAAW,EAClB,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAsC1B;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAUlD;AASD,KAAK,cAAc,GAAG;IAAE,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAE/E,UAAU,cAAc;IACtB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;KAAE,CAAC;CACxD;AACD,KAAK,WAAW,GAAG,CAAC,GAAG,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;AAE3C;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,aAAkB,IAGtC,KAAK,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,KAAG,OAAO,CAAC,IAAI,CAAC,CAiB1F;AAED,UAAU,cAAe,SAAQ,cAAc;IAC7C,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AACD,UAAU,gBAAgB;IACxB,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;KAAE,CAAC;CACxD;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,aAAkB,IAGvC,SAAS,cAAc,EAAE,OAAO,gBAAgB,KAAG,OAAO,CAAC,IAAI,CAAC,CAgB/E;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,UAAU,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,OAAO,CAGpG;AAUD,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IACjD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAyB/E"}
package/dist/auth.js CHANGED
@@ -5,8 +5,10 @@
5
5
  * session cookie / `Authorization` header to core's `GET /api/auth/me`, which
6
6
  * is the single source of truth for identity and group membership. Every
7
7
  * backend reimplemented this proxy (`authMiddleware.ts` / `auth.ts`); this is
8
- * the canonical version plus thin Express and Fastify adapters.
8
+ * the canonical version plus thin Express and Fastify adapters, and an offline
9
+ * HS256 `verifyJwtLocal` for backends that hold core's JWT secret.
9
10
  */
11
+ import { createHmac, timingSafeEqual } from 'node:crypto';
10
12
  /**
11
13
  * Verify a caller against core. Returns the `AuthUser` on success, or `null`
12
14
  * if no credentials were supplied or core rejected them. Throws only when core
@@ -121,3 +123,45 @@ export function userInAnyGroup(user, groupIds) {
121
123
  const set = groupIds instanceof Set ? groupIds : new Set(groupIds);
122
124
  return user.groupIds.some((id) => set.has(id));
123
125
  }
126
+ /**
127
+ * Verify an **HS256** JWT locally against a shared `secret` (no network call).
128
+ * Returns the decoded payload, or `null` if the token is malformed, the
129
+ * signature is invalid, the algorithm isn't HS256, or it has expired. The
130
+ * caller supplies the secret matching core's `JWT_SECRET`.
131
+ */
132
+ export function verifyJwtLocal(token, secret) {
133
+ if (!token || !secret)
134
+ return null;
135
+ const parts = token.split('.');
136
+ if (parts.length !== 3)
137
+ return null;
138
+ const [headerB64, payloadB64, sigB64] = parts;
139
+ let header;
140
+ let payload;
141
+ try {
142
+ header = JSON.parse(base64UrlDecode(headerB64));
143
+ payload = JSON.parse(base64UrlDecode(payloadB64));
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ if (header.alg !== 'HS256')
149
+ return null;
150
+ const expected = base64ToUrl(createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest('base64'));
151
+ const a = new TextEncoder().encode(expected);
152
+ const b = new TextEncoder().encode(sigB64);
153
+ if (a.length !== b.length || !timingSafeEqual(a, b))
154
+ return null;
155
+ if (typeof payload.exp === 'number' && Date.now() / 1000 >= payload.exp)
156
+ return null;
157
+ return payload;
158
+ }
159
+ function base64UrlDecode(s) {
160
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '=');
161
+ const bin = atob(b64); // DOM lib: base64 → latin1 binary string
162
+ const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
163
+ return new TextDecoder().decode(bytes); // reinterpret as UTF-8
164
+ }
165
+ function base64ToUrl(b64) {
166
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
167
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * CoreApiClient — a thin, typed client over core's `/api/dynamic/<collection>`
3
- * CRUD surface (plus the file-upload and decrypt endpoints).
3
+ * CRUD surface (plus file upload/download and decrypt endpoints).
4
4
  *
5
5
  * Every app backend on the platform previously hand-rolled its own copy of
6
6
  * this class (`coreApiClient.ts`), each drifting in features and error
@@ -9,8 +9,8 @@
9
9
  * Auth: writes use the app's functional `x-api-key` (auto-provisioned by core
10
10
  * on registration — see `startAppRegistration`), and by default set
11
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.
12
+ * through the webhook pipeline. To act as an end user (forwarding their
13
+ * session), use `asUser(...)` for a scoped client, or `updateAsUser`.
14
14
  */
15
15
  export declare class CoreApiError extends Error {
16
16
  readonly status: number;
@@ -28,6 +28,27 @@ export type CoreApiConfig = {
28
28
  */
29
29
  skipWebhooks?: boolean;
30
30
  };
31
+ /** Per-call write controls (create/update/upsert/bulk). */
32
+ export type WriteOptions = {
33
+ /** Override the client's skip-webhooks behaviour for this single call. */
34
+ skipWebhooks?: boolean;
35
+ /** Extra headers merged into this request (e.g. an end-user Authorization). */
36
+ headers?: Record<string, string>;
37
+ };
38
+ /** Read controls for `get`/`list`. */
39
+ export type GetOptions = {
40
+ /** `false` appends `?populate=0` so x-ref fields stay as id strings. */
41
+ populate?: boolean;
42
+ };
43
+ /** Credentials to act as an end user (forwarded to core). */
44
+ export type UserCredentials = {
45
+ /** Full `Authorization` header value (e.g. `Bearer <jwt>`). */
46
+ authorization?: string;
47
+ /** Raw `Cookie` header value (forwards the user's session cookie). */
48
+ cookie?: string;
49
+ /** Bare JWT — sent as `Authorization: Bearer <jwt>` if `authorization` is unset. */
50
+ jwt?: string;
51
+ };
31
52
  export type BulkUpsertResult = {
32
53
  upsertedCount: number;
33
54
  modifiedCount: number;
@@ -36,6 +57,22 @@ export type BulkUpsertResult = {
36
57
  error: string;
37
58
  }>;
38
59
  };
60
+ export type BulkCreateResult<T = Record<string, unknown>> = {
61
+ ok: boolean;
62
+ created: number;
63
+ failed: number;
64
+ results: Array<{
65
+ index: number;
66
+ ok: boolean;
67
+ doc?: T;
68
+ error?: string;
69
+ }>;
70
+ };
71
+ export type UpsertResult<T = Record<string, unknown>> = {
72
+ ok: boolean;
73
+ created: boolean;
74
+ doc: T;
75
+ };
39
76
  export type UploadFileOptions = {
40
77
  filename: string;
41
78
  mimeType: string;
@@ -44,6 +81,11 @@ export type UploadFileOptions = {
44
81
  groupIds?: string[];
45
82
  title?: string;
46
83
  };
84
+ export type DownloadedFile = {
85
+ data: Uint8Array;
86
+ contentType: string;
87
+ filename?: string;
88
+ };
47
89
  export declare class CoreApiClient {
48
90
  private readonly baseUrl;
49
91
  private readonly skipWebhooks;
@@ -55,19 +97,40 @@ export declare class CoreApiClient {
55
97
  getApiKey(): string;
56
98
  /** Whether a non-empty API key is present — gate writes on this post-registration. */
57
99
  isReady(): boolean;
100
+ /**
101
+ * Return a client scoped to act AS an end user — forwards their
102
+ * `Authorization`/`Cookie` so core attributes reads/writes (and RBAC) to
103
+ * that human. By default the functional `x-api-key` is dropped (core treats
104
+ * an api-key match as the system actor, overriding the user identity). Pass
105
+ * `{ keepApiKey: true }` to send BOTH (some apps authorise with the user
106
+ * session while still proving the app's identity). The returned client has
107
+ * the full method surface (list/get/create/update/delete/upsert/uploadFile…).
108
+ */
109
+ asUser(creds: UserCredentials, opts?: {
110
+ keepApiKey?: boolean;
111
+ }): CoreApiClient;
58
112
  /** Headers minus `Content-Type` — for requests with no JSON body (DELETE) or multipart. */
59
113
  private headersWithoutContentType;
114
+ /** Build write headers with per-call skip-webhooks / extra-header overrides. */
115
+ private writeHeaders;
60
116
  /**
61
117
  * List documents. `query` maps to core's dynamic list params, e.g.
62
- * `{ _l: '50', _s: '-createdAt', someField: 'x' }`. Defaults to `_l=500`.
118
+ * `{ _l: '50', _s: '-createdAt', populate: '0', someField: 'x' }`. Defaults
119
+ * to `_l=500`; override by passing your own `_l`.
63
120
  */
64
121
  list<T = Record<string, unknown>>(collection: string, query?: Record<string, string>): Promise<T[]>;
122
+ /**
123
+ * Count documents matching `filter` (field filters + optional `_f` advanced
124
+ * JSON) without fetching them — `GET /api/dynamic/:collection/count`.
125
+ */
126
+ count(collection: string, filter?: Record<string, string>): Promise<number>;
65
127
  /**
66
128
  * Fetch one document by id. Returns `null` for a genuine 404; throws
67
129
  * `CoreApiError` for transient failures (401/403/429/5xx) so callers can
68
- * distinguish "doesn't exist" from "couldn't read it".
130
+ * distinguish "doesn't exist" from "couldn't read it". Pass
131
+ * `{ populate: false }` to keep x-ref fields as id strings (`?populate=0`).
69
132
  */
70
- get<T = Record<string, unknown>>(collection: string, id: string): Promise<T | null>;
133
+ get<T = Record<string, unknown>>(collection: string, id: string, opts?: GetOptions): Promise<T | null>;
71
134
  /**
72
135
  * Find a single document by an indexed field. Returns `null` for no match;
73
136
  * throws `CoreApiError` on transient failure (never masks a read error as
@@ -75,27 +138,47 @@ export declare class CoreApiClient {
75
138
  */
76
139
  findBy<T = Record<string, unknown>>(collection: string, field: string, value: string): Promise<T | null>;
77
140
  /** Create a document. Throws `CoreApiError` on failure. */
78
- create<T = Record<string, unknown>>(collection: string, body: Record<string, unknown>): Promise<T>;
141
+ create<T = Record<string, unknown>>(collection: string, body: Record<string, unknown>, opts?: WriteOptions): Promise<T>;
142
+ /**
143
+ * Create up to 500 documents in one call — `POST /api/dynamic/:collection/bulk`.
144
+ * Returns core's `{ ok, created, failed, results }` (per-doc success/error).
145
+ */
146
+ bulkCreate<T = Record<string, unknown>>(collection: string, documents: Record<string, unknown>[], opts?: WriteOptions): Promise<BulkCreateResult<T>>;
79
147
  /** 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>;
148
+ update<T = Record<string, unknown>>(collection: string, id: string, body: Record<string, unknown>, opts?: WriteOptions): Promise<T>;
81
149
  /**
82
150
  * Update a document AS the given user, by sending their short-lived JWT
83
151
  * (`Authorization: Bearer`) instead of the functional `x-api-key`. Core then
84
152
  * 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.
153
+ * `_meta.lastModifiedByUserId`. For richer user-scoped flows (cookie
154
+ * forwarding, multipart upload, reads) prefer `asUser(...)`.
88
155
  */
89
156
  updateAsUser<T = Record<string, unknown>>(collection: string, id: string, body: Record<string, unknown>, userJwt: string): Promise<T>;
90
157
  /** Soft-delete a document by id. Returns `true` on success or 404 (already gone). */
91
158
  delete(collection: string, id: string): Promise<boolean>;
92
159
  /**
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.
160
+ * Permanently delete documents matching `query`
161
+ * `POST /api/dynamic/:collection/hard-delete-by-filter`. API-key auth only
162
+ * (system operation); the query must be non-empty. Returns `{ ok, deletedCount }`.
163
+ */
164
+ hardDeleteByFilter(collection: string, query: Record<string, unknown>): Promise<{
165
+ ok: boolean;
166
+ deletedCount: number;
167
+ }>;
168
+ /**
169
+ * Atomically upsert a document by a single unique field, via core's
170
+ * server-side `POST /api/dynamic/:collection/upsert`. Returns the document
171
+ * (or `null`). Falls back to findBy + update/create on older cores that 404
172
+ * the upsert route. For composite keys / the full `{ ok, created }` result,
173
+ * use `upsertOn`.
97
174
  */
98
175
  upsert<T = Record<string, unknown>>(collection: string, matchField: string, matchValue: string, body: Record<string, unknown>): Promise<T | null>;
176
+ /**
177
+ * Atomically upsert by a COMPOSITE match key — `POST .../upsert` with
178
+ * `{ matchOn, document }`. Returns the full `{ ok, created, doc }` result so
179
+ * callers can branch on whether a row was created vs updated.
180
+ */
181
+ upsertOn<T = Record<string, unknown>>(collection: string, matchOn: string[], document: Record<string, unknown>, opts?: WriteOptions): Promise<UpsertResult<T>>;
99
182
  private upsertLegacy;
100
183
  /**
101
184
  * Bulk-upsert up to 500 documents, matched by a composite key (typically
@@ -107,15 +190,26 @@ export declare class CoreApiClient {
107
190
  * Upload a file to core's file API (`POST /api/files/uploadDirect`). Returns
108
191
  * the created file document (`{ _id, originalName }`) for referencing.
109
192
  * Accepts a `Uint8Array` (a Node `Buffer` is a `Uint8Array`, so it works too).
193
+ * To upload AS an end user, call `asUser(creds).uploadFile(...)`.
110
194
  */
111
195
  uploadFile(data: Uint8Array, opts: UploadFileOptions): Promise<{
112
196
  _id: string;
113
197
  originalName: string;
114
198
  }>;
199
+ /**
200
+ * Download a file's bytes — `GET /api/files/:id/content`. Returns the bytes
201
+ * plus content-type/filename, or `null` on 404. To download AS an end user,
202
+ * call `asUser(creds).downloadFile(...)`.
203
+ */
204
+ downloadFile(fileId: string): Promise<DownloadedFile | null>;
115
205
  /**
116
206
  * 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.
207
+ * endpoint. Returns a map of field name → plaintext. By default returns
208
+ * `null` on failure; pass `{ throwOnError: true }` to throw `CoreApiError`
209
+ * instead. To decrypt AS an end user, call `asUser(creds).decrypt(...)`.
118
210
  */
119
- decrypt(collection: string, id: string): Promise<Record<string, string | null> | null>;
211
+ decrypt(collection: string, id: string, opts?: {
212
+ throwOnError?: boolean;
213
+ }): Promise<Record<string, string | null> | null>;
120
214
  }
121
215
  //# sourceMappingURL=core-api-client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"core-api-client.d.ts","sourceRoot":"","sources":["../src/core-api-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"core-api-client.d.ts","sourceRoot":"","sources":["../src/core-api-client.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * CoreApiClient — a thin, typed client over core's `/api/dynamic/<collection>`
3
- * CRUD surface (plus the file-upload and decrypt endpoints).
3
+ * CRUD surface (plus file upload/download and decrypt endpoints).
4
4
  *
5
5
  * Every app backend on the platform previously hand-rolled its own copy of
6
6
  * this class (`coreApiClient.ts`), each drifting in features and error
@@ -9,8 +9,8 @@
9
9
  * Auth: writes use the app's functional `x-api-key` (auto-provisioned by core
10
10
  * on registration — see `startAppRegistration`), and by default set
11
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.
12
+ * through the webhook pipeline. To act as an end user (forwarding their
13
+ * session), use `asUser(...)` for a scoped client, or `updateAsUser`.
14
14
  */
15
15
  export class CoreApiError extends Error {
16
16
  status;
@@ -47,14 +47,49 @@ export class CoreApiClient {
47
47
  isReady() {
48
48
  return (this.headers['x-api-key']?.length ?? 0) > 0;
49
49
  }
50
+ /**
51
+ * Return a client scoped to act AS an end user — forwards their
52
+ * `Authorization`/`Cookie` so core attributes reads/writes (and RBAC) to
53
+ * that human. By default the functional `x-api-key` is dropped (core treats
54
+ * an api-key match as the system actor, overriding the user identity). Pass
55
+ * `{ keepApiKey: true }` to send BOTH (some apps authorise with the user
56
+ * session while still proving the app's identity). The returned client has
57
+ * the full method surface (list/get/create/update/delete/upsert/uploadFile…).
58
+ */
59
+ asUser(creds, opts = {}) {
60
+ const scoped = new CoreApiClient({
61
+ baseUrl: this.baseUrl,
62
+ apiKey: opts.keepApiKey ? (this.headers['x-api-key'] ?? null) : null,
63
+ skipWebhooks: this.skipWebhooks,
64
+ });
65
+ if (creds.authorization)
66
+ scoped.headers['Authorization'] = creds.authorization;
67
+ else if (creds.jwt)
68
+ scoped.headers['Authorization'] = `Bearer ${creds.jwt}`;
69
+ if (creds.cookie)
70
+ scoped.headers['Cookie'] = creds.cookie;
71
+ return scoped;
72
+ }
50
73
  /** Headers minus `Content-Type` — for requests with no JSON body (DELETE) or multipart. */
51
74
  headersWithoutContentType() {
52
75
  const { 'Content-Type': _ct, ...rest } = this.headers;
53
76
  return rest;
54
77
  }
78
+ /** Build write headers with per-call skip-webhooks / extra-header overrides. */
79
+ writeHeaders(opts) {
80
+ const h = { ...this.headers };
81
+ if (opts?.skipWebhooks === false)
82
+ delete h['x-theitemapp-skip-webhooks'];
83
+ else if (opts?.skipWebhooks === true)
84
+ h['x-theitemapp-skip-webhooks'] = '1';
85
+ if (opts?.headers)
86
+ Object.assign(h, opts.headers);
87
+ return h;
88
+ }
55
89
  /**
56
90
  * List documents. `query` maps to core's dynamic list params, e.g.
57
- * `{ _l: '50', _s: '-createdAt', someField: 'x' }`. Defaults to `_l=500`.
91
+ * `{ _l: '50', _s: '-createdAt', populate: '0', someField: 'x' }`. Defaults
92
+ * to `_l=500`; override by passing your own `_l`.
58
93
  */
59
94
  async list(collection, query) {
60
95
  const params = new URLSearchParams({ _l: '500', ...query });
@@ -65,13 +100,28 @@ export class CoreApiClient {
65
100
  const data = await res.json();
66
101
  return Array.isArray(data) ? data : [];
67
102
  }
103
+ /**
104
+ * Count documents matching `filter` (field filters + optional `_f` advanced
105
+ * JSON) without fetching them — `GET /api/dynamic/:collection/count`.
106
+ */
107
+ async count(collection, filter) {
108
+ const qs = new URLSearchParams({ ...filter }).toString();
109
+ const url = `${this.baseUrl}/api/dynamic/${collection}/count${qs ? `?${qs}` : ''}`;
110
+ const res = await fetch(url, { headers: this.headers });
111
+ if (!res.ok)
112
+ throw new CoreApiError('COUNT', collection, res.status, await safeText(res));
113
+ const data = (await res.json());
114
+ return typeof data === 'number' ? data : Number(data?.count ?? 0);
115
+ }
68
116
  /**
69
117
  * Fetch one document by id. Returns `null` for a genuine 404; throws
70
118
  * `CoreApiError` for transient failures (401/403/429/5xx) so callers can
71
- * distinguish "doesn't exist" from "couldn't read it".
119
+ * distinguish "doesn't exist" from "couldn't read it". Pass
120
+ * `{ populate: false }` to keep x-ref fields as id strings (`?populate=0`).
72
121
  */
73
- async get(collection, id) {
74
- const url = `${this.baseUrl}/api/dynamic/${collection}/${encodeURIComponent(id)}`;
122
+ async get(collection, id, opts) {
123
+ const qs = opts?.populate === false ? '?populate=0' : '';
124
+ const url = `${this.baseUrl}/api/dynamic/${collection}/${encodeURIComponent(id)}${qs}`;
75
125
  const res = await fetch(url, { headers: this.headers });
76
126
  if (res.ok)
77
127
  return (await res.json());
@@ -94,17 +144,30 @@ export class CoreApiClient {
94
144
  return Array.isArray(data) ? (data[0] ?? null) : null;
95
145
  }
96
146
  /** Create a document. Throws `CoreApiError` on failure. */
97
- async create(collection, body) {
147
+ async create(collection, body, opts) {
98
148
  const url = `${this.baseUrl}/api/dynamic/${collection}`;
99
- const res = await fetch(url, { method: 'POST', headers: this.headers, body: JSON.stringify(body) });
149
+ const res = await fetch(url, { method: 'POST', headers: this.writeHeaders(opts), body: JSON.stringify(body) });
100
150
  if (!res.ok)
101
151
  throw new CoreApiError('CREATE', collection, res.status, await safeText(res));
102
152
  return (await res.json());
103
153
  }
154
+ /**
155
+ * Create up to 500 documents in one call — `POST /api/dynamic/:collection/bulk`.
156
+ * Returns core's `{ ok, created, failed, results }` (per-doc success/error).
157
+ */
158
+ async bulkCreate(collection, documents, opts) {
159
+ if (documents.length === 0)
160
+ return { ok: true, created: 0, failed: 0, results: [] };
161
+ const url = `${this.baseUrl}/api/dynamic/${collection}/bulk`;
162
+ const res = await fetch(url, { method: 'POST', headers: this.writeHeaders(opts), body: JSON.stringify({ documents }) });
163
+ if (!res.ok)
164
+ throw new CoreApiError('BULK_CREATE', collection, res.status, await safeText(res));
165
+ return (await res.json());
166
+ }
104
167
  /** Update a document by id (PUT). Throws `CoreApiError` on failure. */
105
- async update(collection, id, body) {
168
+ async update(collection, id, body, opts) {
106
169
  const url = `${this.baseUrl}/api/dynamic/${collection}/${encodeURIComponent(id)}`;
107
- const res = await fetch(url, { method: 'PUT', headers: this.headers, body: JSON.stringify(body) });
170
+ const res = await fetch(url, { method: 'PUT', headers: this.writeHeaders(opts), body: JSON.stringify(body) });
108
171
  if (!res.ok)
109
172
  throw new CoreApiError('UPDATE', `${collection}/${id}`, res.status, await safeText(res));
110
173
  return (await res.json());
@@ -113,9 +176,8 @@ export class CoreApiClient {
113
176
  * Update a document AS the given user, by sending their short-lived JWT
114
177
  * (`Authorization: Bearer`) instead of the functional `x-api-key`. Core then
115
178
  * 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.
179
+ * `_meta.lastModifiedByUserId`. For richer user-scoped flows (cookie
180
+ * forwarding, multipart upload, reads) prefer `asUser(...)`.
119
181
  */
120
182
  async updateAsUser(collection, id, body, userJwt) {
121
183
  const url = `${this.baseUrl}/api/dynamic/${collection}/${encodeURIComponent(id)}`;
@@ -144,10 +206,26 @@ export class CoreApiClient {
144
206
  throw new CoreApiError('DELETE', `${collection}/${id}`, res.status, await safeText(res));
145
207
  }
146
208
  /**
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.
209
+ * Permanently delete documents matching `query`
210
+ * `POST /api/dynamic/:collection/hard-delete-by-filter`. API-key auth only
211
+ * (system operation); the query must be non-empty. Returns `{ ok, deletedCount }`.
212
+ */
213
+ async hardDeleteByFilter(collection, query) {
214
+ if (!query || Object.keys(query).length === 0) {
215
+ throw new Error('[coreApi] hardDeleteByFilter requires a non-empty query');
216
+ }
217
+ const url = `${this.baseUrl}/api/dynamic/${collection}/hard-delete-by-filter`;
218
+ const res = await fetch(url, { method: 'POST', headers: this.headers, body: JSON.stringify({ query }) });
219
+ if (!res.ok)
220
+ throw new CoreApiError('HARD_DELETE_BY_FILTER', collection, res.status, await safeText(res));
221
+ return (await res.json());
222
+ }
223
+ /**
224
+ * Atomically upsert a document by a single unique field, via core's
225
+ * server-side `POST /api/dynamic/:collection/upsert`. Returns the document
226
+ * (or `null`). Falls back to findBy + update/create on older cores that 404
227
+ * the upsert route. For composite keys / the full `{ ok, created }` result,
228
+ * use `upsertOn`.
151
229
  */
152
230
  async upsert(collection, matchField, matchValue, body) {
153
231
  const fullDoc = { ...body, [matchField]: matchValue };
@@ -165,6 +243,22 @@ export class CoreApiClient {
165
243
  return this.upsertLegacy(collection, matchField, matchValue, body);
166
244
  throw new CoreApiError('UPSERT', collection, res.status, await safeText(res));
167
245
  }
246
+ /**
247
+ * Atomically upsert by a COMPOSITE match key — `POST .../upsert` with
248
+ * `{ matchOn, document }`. Returns the full `{ ok, created, doc }` result so
249
+ * callers can branch on whether a row was created vs updated.
250
+ */
251
+ async upsertOn(collection, matchOn, document, opts) {
252
+ const url = `${this.baseUrl}/api/dynamic/${collection}/upsert`;
253
+ const res = await fetch(url, {
254
+ method: 'POST',
255
+ headers: this.writeHeaders(opts),
256
+ body: JSON.stringify({ matchOn, document }),
257
+ });
258
+ if (!res.ok)
259
+ throw new CoreApiError('UPSERT_ON', collection, res.status, await safeText(res));
260
+ return (await res.json());
261
+ }
168
262
  async upsertLegacy(collection, matchField, matchValue, body) {
169
263
  const existing = await this.findBy(collection, matchField, matchValue);
170
264
  if (existing && typeof existing._id === 'string') {
@@ -194,6 +288,7 @@ export class CoreApiClient {
194
288
  * Upload a file to core's file API (`POST /api/files/uploadDirect`). Returns
195
289
  * the created file document (`{ _id, originalName }`) for referencing.
196
290
  * Accepts a `Uint8Array` (a Node `Buffer` is a `Uint8Array`, so it works too).
291
+ * To upload AS an end user, call `asUser(creds).uploadFile(...)`.
197
292
  */
198
293
  async uploadFile(data, opts) {
199
294
  const form = new FormData();
@@ -215,15 +310,38 @@ export class CoreApiClient {
215
310
  throw new CoreApiError('UPLOAD_FILE', opts.filename, res.status, await safeText(res));
216
311
  return (await res.json());
217
312
  }
313
+ /**
314
+ * Download a file's bytes — `GET /api/files/:id/content`. Returns the bytes
315
+ * plus content-type/filename, or `null` on 404. To download AS an end user,
316
+ * call `asUser(creds).downloadFile(...)`.
317
+ */
318
+ async downloadFile(fileId) {
319
+ const url = `${this.baseUrl}/api/files/${encodeURIComponent(fileId)}/content`;
320
+ const res = await fetch(url, { headers: this.headersWithoutContentType() });
321
+ if (res.status === 404)
322
+ return null;
323
+ if (!res.ok)
324
+ throw new CoreApiError('DOWNLOAD_FILE', fileId, res.status, await safeText(res));
325
+ const data = new Uint8Array(await res.arrayBuffer());
326
+ const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
327
+ const cd = res.headers.get('content-disposition') ?? '';
328
+ const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(cd);
329
+ return { data, contentType, filename: m?.[1] };
330
+ }
218
331
  /**
219
332
  * 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.
333
+ * endpoint. Returns a map of field name → plaintext. By default returns
334
+ * `null` on failure; pass `{ throwOnError: true }` to throw `CoreApiError`
335
+ * instead. To decrypt AS an end user, call `asUser(creds).decrypt(...)`.
221
336
  */
222
- async decrypt(collection, id) {
337
+ async decrypt(collection, id, opts) {
223
338
  const url = `${this.baseUrl}/api/internal/decrypt/${collection}/${encodeURIComponent(id)}`;
224
339
  const res = await fetch(url, { headers: this.headers });
225
- if (!res.ok)
340
+ if (!res.ok) {
341
+ if (opts?.throwOnError)
342
+ throw new CoreApiError('DECRYPT', `${collection}/${id}`, res.status, await safeText(res));
226
343
  return null;
344
+ }
227
345
  return (await res.json());
228
346
  }
229
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loynazkovacs/theitemapp-backend-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Server-side SDK for TheItemApp app backends: core API client, registration lifecycle, and auth verification.",