@classytic/arc-next 0.9.1 → 0.11.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
@@ -8,6 +8,20 @@ React + TanStack Query SDK for the Arc backend framework. Typed CRUD hooks, opti
8
8
  npm install @classytic/arc-next
9
9
  ```
10
10
 
11
+ ## Type flow — use WIRE types for `T`
12
+
13
+ `createCrudApi<T>`'s generic should be the kernel/module's exported **wire type**
14
+ (plain JSON shape) — never a mongoose-flavored document type. Kernel → API →
15
+ frontend then stays one type flow with zero casts:
16
+
17
+ ```ts
18
+ import type { OrderWire } from '@classytic/order/wire'; // plain JSON shape
19
+ const orders = createCrudApi<OrderWire>('orders');
20
+ ```
21
+
22
+ Server-side counterpart: arc-* modules export their wire types per the
23
+ module-publishing convention.
24
+
11
25
  ## Setup
12
26
 
13
27
  Call once at app init from a `"use client"` provider:
package/dist/api.d.ts CHANGED
@@ -123,6 +123,50 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
123
123
  params?: QueryParams;
124
124
  options?: Omit<RequestOptions, 'token' | 'organizationId'>;
125
125
  }): Promise<PaginatedResult<TDoc>>;
126
+ /**
127
+ * Count records matching the filters — arc's list-route dispatch verb
128
+ * (`?_count=true`): same permissions/row-filters/tenant scoping as
129
+ * `getAll`, ZERO documents fetched. Cheapest way to answer "how many".
130
+ */
131
+ count({
132
+ token,
133
+ organizationId,
134
+ params,
135
+ options
136
+ }?: {
137
+ token?: string | null;
138
+ organizationId?: string | null;
139
+ params?: QueryParams;
140
+ options?: Omit<RequestOptions, 'token' | 'organizationId'>;
141
+ }): Promise<number>;
142
+ /** Whether ANY record matches the filters (`?_exists=true`). */
143
+ exists({
144
+ token,
145
+ organizationId,
146
+ params,
147
+ options
148
+ }?: {
149
+ token?: string | null;
150
+ organizationId?: string | null;
151
+ params?: QueryParams;
152
+ options?: Omit<RequestOptions, 'token' | 'organizationId'>;
153
+ }): Promise<boolean>;
154
+ /** Distinct values of a field across matching records (`?_distinct=field`). */
155
+ distinct<TValue = unknown>({
156
+ token,
157
+ organizationId,
158
+ field,
159
+ params,
160
+ options
161
+ }: {
162
+ token?: string | null;
163
+ organizationId?: string | null;
164
+ field: string;
165
+ params?: QueryParams;
166
+ options?: Omit<RequestOptions, 'token' | 'organizationId'>;
167
+ }): Promise<TValue[]>;
168
+ /** Shared raw GET against the list route (dispatch verbs bypass pagination parsing). */
169
+ private getAllRaw;
126
170
  getById({
127
171
  token,
128
172
  organizationId,
package/dist/api.js CHANGED
@@ -105,6 +105,60 @@ var BaseApi = class {
105
105
  if (organizationId) requestOptions.organizationId = organizationId;
106
106
  return this.requestFn("GET", `${this.baseUrl}?${queryString}`, this.withHeaders(requestOptions));
107
107
  }
108
+ /**
109
+ * Count records matching the filters — arc's list-route dispatch verb
110
+ * (`?_count=true`): same permissions/row-filters/tenant scoping as
111
+ * `getAll`, ZERO documents fetched. Cheapest way to answer "how many".
112
+ */
113
+ async count({ token = null, organizationId = null, params = {}, options = {} } = {}) {
114
+ return extractVerbField(await this.getAllRaw({
115
+ token,
116
+ organizationId,
117
+ params: {
118
+ ...params,
119
+ _count: true
120
+ },
121
+ options
122
+ }), "count");
123
+ }
124
+ /** Whether ANY record matches the filters (`?_exists=true`). */
125
+ async exists({ token = null, organizationId = null, params = {}, options = {} } = {}) {
126
+ return extractVerbField(await this.getAllRaw({
127
+ token,
128
+ organizationId,
129
+ params: {
130
+ ...params,
131
+ _exists: true
132
+ },
133
+ options
134
+ }), "exists");
135
+ }
136
+ /** Distinct values of a field across matching records (`?_distinct=field`). */
137
+ async distinct({ token = null, organizationId = null, field, params = {}, options = {} }) {
138
+ if (!field) throw new Error("field is required");
139
+ return extractVerbField(await this.getAllRaw({
140
+ token,
141
+ organizationId,
142
+ params: {
143
+ ...params,
144
+ _distinct: field
145
+ },
146
+ options
147
+ }), "values");
148
+ }
149
+ /** Shared raw GET against the list route (dispatch verbs bypass pagination parsing). */
150
+ async getAllRaw({ token = null, organizationId = null, params = {}, options = {} }) {
151
+ const mergedParams = {
152
+ ...this.config.defaultParams,
153
+ ...params
154
+ };
155
+ const processedParams = this.prepareParams(mergedParams);
156
+ const queryString = this.createQueryString(processedParams);
157
+ const requestOptions = { ...this.withCacheDefault(options) };
158
+ if (token) requestOptions.token = token;
159
+ if (organizationId) requestOptions.organizationId = organizationId;
160
+ return this.requestFn("GET", `${this.baseUrl}?${queryString}`, this.withHeaders(requestOptions));
161
+ }
108
162
  async getById({ token = null, organizationId = null, id, params = {}, options = {} }) {
109
163
  if (!id) throw new Error("ID is required");
110
164
  const queryString = this.createQueryString(params);
@@ -255,6 +309,18 @@ function isKeysetPagination(response) {
255
309
  function isAggregatePagination(response) {
256
310
  return "method" in response && response.method === "aggregate";
257
311
  }
312
+ /**
313
+ * Dispatch-verb responses are small objects (`{ count }`, `{ exists }`,
314
+ * `{ values }`). Parse defensively across envelope variants (bare vs
315
+ * `{ data: ... }`) so minor server envelope changes don't break clients.
316
+ */
317
+ function extractVerbField(res, field) {
318
+ const r = res;
319
+ if (r && field in r) return r[field];
320
+ const d = r?.data ?? null;
321
+ if (d && field in d) return d[field];
322
+ throw new Error(`[arc-next] unexpected dispatch-verb response shape (missing '${field}')`);
323
+ }
258
324
 
259
325
  //#endregion
260
326
  export { BaseApi, createCrudApi, isAggregatePagination, isKeysetPagination, isOffsetPagination };
package/dist/cache.d.ts CHANGED
@@ -110,6 +110,20 @@ interface QueryKeys {
110
110
  * is identical between server (prefetch) and client (hooks), so RSC SSR
111
111
  * hydration matches what client-side `useList`/`useDetail` produce.
112
112
  */
113
+ /**
114
+ * Org-normalized key params — the ONE way hooks AND prefetchers merge
115
+ * `organizationId` into a query-key params object.
116
+ *
117
+ * Why this exists: TanStack's key hash (`hashKey`) drops `undefined` object
118
+ * values but KEEPS `null` — so `{ organizationId: null }` and `{}` are
119
+ * DIFFERENT cache entries. Auth resolution returns `organizationId: null`
120
+ * for org-less callers (public storefronts), while server prefetchers
121
+ * conditionally omitted the field — producing keys that never matched and
122
+ * silently defeating SSR hydration (the client refetched everything).
123
+ * Normalizing here (omit when nullish) makes hook and prefetch keys equal
124
+ * by construction. Covered by tests/key-parity.test.ts.
125
+ */
126
+ declare function withOrgParams(organizationId: string | null | undefined, params?: Record<string, unknown>): Record<string, unknown>;
113
127
  declare function createQueryKeys(entityKey: string): QueryKeys;
114
128
  interface CacheUtils<T> {
115
129
  invalidateAll: (client: QueryClient) => Promise<void>;
@@ -149,4 +163,4 @@ interface CacheUtils<T> {
149
163
  */
150
164
  declare function createCacheUtils<T>(KEYS: QueryKeys): CacheUtils<T>;
151
165
  //#endregion
152
- export { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache };
166
+ export { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache, withOrgParams };
package/dist/cache.js CHANGED
@@ -221,6 +221,26 @@ function mergeItemIntoListPage(page, targetId, item, idField) {
221
221
  * is identical between server (prefetch) and client (hooks), so RSC SSR
222
222
  * hydration matches what client-side `useList`/`useDetail` produce.
223
223
  */
224
+ /**
225
+ * Org-normalized key params — the ONE way hooks AND prefetchers merge
226
+ * `organizationId` into a query-key params object.
227
+ *
228
+ * Why this exists: TanStack's key hash (`hashKey`) drops `undefined` object
229
+ * values but KEEPS `null` — so `{ organizationId: null }` and `{}` are
230
+ * DIFFERENT cache entries. Auth resolution returns `organizationId: null`
231
+ * for org-less callers (public storefronts), while server prefetchers
232
+ * conditionally omitted the field — producing keys that never matched and
233
+ * silently defeating SSR hydration (the client refetched everything).
234
+ * Normalizing here (omit when nullish) makes hook and prefetch keys equal
235
+ * by construction. Covered by tests/key-parity.test.ts.
236
+ */
237
+ function withOrgParams(organizationId, params = {}) {
238
+ const { organizationId: _drop, ...rest } = params;
239
+ return organizationId ? {
240
+ organizationId,
241
+ ...rest
242
+ } : rest;
243
+ }
224
244
  function createQueryKeys(entityKey) {
225
245
  return {
226
246
  all: [entityKey],
@@ -300,4 +320,4 @@ function createCacheUtils(KEYS) {
300
320
  }
301
321
 
302
322
  //#endregion
303
- export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache };
323
+ export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache, withOrgParams };
package/dist/client.d.ts CHANGED
@@ -141,6 +141,33 @@ declare function isAbortError(error: unknown): boolean;
141
141
  * if (isArcErrorCode(error, 'ORG_CONTEXT_REQUIRED')) promptOrgSelector();
142
142
  */
143
143
  declare function isArcErrorCode(error: unknown, code: ArcErrorCode): error is ArcApiError;
144
+ /** `details` payload of arc's `quota.exceeded` 429 (`requireQuota`). */
145
+ interface QuotaDetails {
146
+ /** The metered counter, e.g. `ai.tokens`, `export.runs`. */
147
+ kind: string;
148
+ used: number;
149
+ limit: number;
150
+ /** Billing period key, `YYYY-MM`. */
151
+ period: string;
152
+ /** ISO timestamp of the next period start — render "resets {date}". */
153
+ resetsAt: string;
154
+ }
155
+ /**
156
+ * Type guard for arc's quota denial (429 `quota.exceeded` from
157
+ * `requireQuota`). Render a meter, not a generic failure:
158
+ *
159
+ * @example
160
+ * if (isQuotaExceeded(err)) {
161
+ * const q = getQuotaDetails(err);
162
+ * toast(`${q.used.toLocaleString()} of ${q.limit.toLocaleString()} ${q.kind} used — resets ${new Date(q.resetsAt).toLocaleDateString()}`);
163
+ * }
164
+ *
165
+ * NEVER auto-retry these — a monthly quota doesn't reset between retries
166
+ * (the shared query client already refuses; see query-client.ts).
167
+ */
168
+ declare function isQuotaExceeded(error: unknown): error is ArcApiError;
169
+ /** Structured quota details from a `quota.exceeded` error (null when absent/malformed). */
170
+ declare function getQuotaDetails(error: unknown): QuotaDetails | null;
144
171
  /**
145
172
  * Specific predicate for arc's bulk-preset + orgGuard safety code.
146
173
  *
@@ -623,7 +650,19 @@ interface NextFetchOptions {
623
650
  }
624
651
  interface ApiRequestOptions {
625
652
  body?: unknown;
653
+ /**
654
+ * Bearer token for this request. THREE-STATE contract:
655
+ * - **omitted / `undefined`** → inherit the global `configureAuth()` context
656
+ * (auto-injected by `handleApiRequest`, per-client instances, and hooks)
657
+ * - **explicit `null`** → deliberately unauthenticated (public endpoint)
658
+ * - **string** → use exactly this token (wins over the global context)
659
+ */
626
660
  token?: string | null;
661
+ /**
662
+ * Tenant/org id sent as `x-organization-id`. Same three-state contract as
663
+ * `token`: `undefined` = inherit `configureAuth().getOrgId`, `null` = send
664
+ * no org header (platform-scope calls), string = exactly this org.
665
+ */
627
666
  organizationId?: string | null;
628
667
  /** Flattened Next `revalidate` (see `next`). `false` = cache indefinitely. */
629
668
  revalidate?: number | false;
@@ -864,4 +903,4 @@ declare const arc: {
864
903
  delete: <T = unknown>(path: string, opts?: ArcFetchOptions) => Promise<T>;
865
904
  };
866
905
  //#endregion
867
- export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, ClientEncryptionConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, NextFetchOptions, RetryConfig, StreamUrlProtocol, TextResponse, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
906
+ export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, ClientEncryptionConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, NextFetchOptions, QuotaDetails, RetryConfig, StreamUrlProtocol, TextResponse, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, getQuotaDetails, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isValidationError };
package/dist/client.js CHANGED
@@ -201,6 +201,29 @@ function isArcErrorCode(error, code) {
201
201
  return isArcApiError(error) && error.code === code;
202
202
  }
203
203
  /**
204
+ * Type guard for arc's quota denial (429 `quota.exceeded` from
205
+ * `requireQuota`). Render a meter, not a generic failure:
206
+ *
207
+ * @example
208
+ * if (isQuotaExceeded(err)) {
209
+ * const q = getQuotaDetails(err);
210
+ * toast(`${q.used.toLocaleString()} of ${q.limit.toLocaleString()} ${q.kind} used — resets ${new Date(q.resetsAt).toLocaleDateString()}`);
211
+ * }
212
+ *
213
+ * NEVER auto-retry these — a monthly quota doesn't reset between retries
214
+ * (the shared query client already refuses; see query-client.ts).
215
+ */
216
+ function isQuotaExceeded(error) {
217
+ return isArcApiError(error) && error.status === 429 && error.code === "quota.exceeded";
218
+ }
219
+ /** Structured quota details from a `quota.exceeded` error (null when absent/malformed). */
220
+ function getQuotaDetails(error) {
221
+ if (!isQuotaExceeded(error)) return null;
222
+ const d = error.json?.details;
223
+ if (!d || typeof d.kind !== "string" || typeof d.limit !== "number") return null;
224
+ return d;
225
+ }
226
+ /**
204
227
  * Specific predicate for arc's bulk-preset + orgGuard safety code.
205
228
  *
206
229
  * Arc's bulk endpoints (`POST/PATCH/DELETE /:resource/bulk`) reject any call
@@ -877,6 +900,12 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
877
900
  */
878
901
  async function handleApiRequest(method, endpoint, options = {}) {
879
902
  if (!clientConfig) throw new Error("arc-next: Client not configured. Call configureClient({ baseUrl }) before making API requests.");
903
+ if (authConfig) {
904
+ const resolved = { ...options };
905
+ if (resolved.token === void 0) resolved.token = readToken(authConfig.getToken);
906
+ if (resolved.organizationId === void 0) resolved.organizationId = authConfig.getOrgId?.() ?? null;
907
+ return executeRequest(clientConfig, method, endpoint, resolved);
908
+ }
880
909
  return executeRequest(clientConfig, method, endpoint, options);
881
910
  }
882
911
  /**
@@ -1110,4 +1139,4 @@ const arc = {
1110
1139
  };
1111
1140
 
1112
1141
  //#endregion
1113
- export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
1142
+ export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, getQuotaDetails, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isValidationError };
@@ -0,0 +1,86 @@
1
+ //#region src/field-encryption.d.ts
2
+ /**
3
+ * `@classytic/arc-next/field-encryption` — client decrypt for arc's
4
+ * FIELD-mode ALE (`@classytic/arc/encryption` with `mode: 'fields'`).
5
+ *
6
+ * Field-mode responses stay `application/json`; only the configured field
7
+ * VALUES arrive as authenticated `arc.v1` envelopes
8
+ * (`arc.v1.<b64url(kid)>.<b64url(iv)>.<b64url(ct)>.<b64url(tag)>`,
9
+ * AES-256-GCM). This helper parses and decrypts those envelopes with the
10
+ * shared symmetric key, via Web Crypto — zero dependencies, works in Node
11
+ * 22+, Bun, Deno, and React Native (with a Web Crypto polyfill).
12
+ *
13
+ * ── SECURITY: trusted runtimes ONLY ─────────────────────────────────────
14
+ * Field mode is SYMMETRIC — whoever holds the key can decrypt every
15
+ * envelope ever produced under it. That key belongs in a Node BFF, a
16
+ * server component, or a native app's secure storage. It must NEVER ship
17
+ * in a browser bundle: anything readable by browser JavaScript is readable
18
+ * by every visitor. For payloads a browser must decrypt, use the
19
+ * asymmetric JWE path (`@classytic/arc-next/encryption`) instead — that is
20
+ * exactly why this lives in its own subpath the web bundle never imports.
21
+ * ────────────────────────────────────────────────────────────────────────
22
+ *
23
+ * Fail-closed: unknown `kid`, tampered ciphertext, or a malformed
24
+ * `arc.v1.*` token throws — an encrypted field is never silently passed
25
+ * through as ciphertext or dropped.
26
+ *
27
+ * @example Node BFF / server component
28
+ * ```ts
29
+ * import { createFieldDecryption } from '@classytic/arc-next/field-encryption';
30
+ *
31
+ * const ale = createFieldDecryption({
32
+ * // 32-byte AES-256 keys by kid — keep the previous kid during rotation.
33
+ * keys: { 'k-2026-07': keyBytes },
34
+ * });
35
+ *
36
+ * configureClient({
37
+ * baseUrl: env.ARC_URL,
38
+ * afterResponse: async (ctx) => {
39
+ * // arc stamps `x-encrypted: true` on field-mode responses — only
40
+ * // those are scanned; every other response passes through untouched.
41
+ * if (ctx.response.headers.get('x-encrypted') === 'true') {
42
+ * ctx.body = await ale.decryptFieldsDeep(ctx.body);
43
+ * }
44
+ * return ctx;
45
+ * },
46
+ * });
47
+ * ```
48
+ */
49
+ /** Version prefix shared with `@classytic/arc/encryption`'s field cipher. */
50
+ declare const FIELD_ENVELOPE_PREFIX = "arc.v1";
51
+ /** Parsed `arc.v1` envelope — `kid` exposed for key resolution. */
52
+ interface ParsedFieldEnvelope {
53
+ readonly kid: string;
54
+ readonly iv: Uint8Array;
55
+ readonly ciphertext: Uint8Array;
56
+ readonly tag: Uint8Array;
57
+ }
58
+ interface FieldDecryptionOptions {
59
+ /**
60
+ * 32-byte AES-256 keys indexed by `kid`. Keep 2–3 entries across a key
61
+ * rotation so envelopes minted under the previous `kid` still decrypt.
62
+ */
63
+ keys: Record<string, Uint8Array>;
64
+ }
65
+ interface FieldDecryption {
66
+ /** Decrypt one `arc.v1` envelope → plaintext string. Throws on tamper/unknown kid. */
67
+ decryptField(token: string): Promise<string>;
68
+ /**
69
+ * Walk a parsed JSON value and decrypt every `arc.v1.*` string in place
70
+ * (arrays and plain objects recursed; other types untouched). Returns the
71
+ * same reference for pipeline ergonomics.
72
+ */
73
+ decryptFieldsDeep<T>(data: T): Promise<T>;
74
+ }
75
+ /** Parse an `arc.v1` envelope, or `null` when the token isn't one. */
76
+ declare function parseFieldEnvelope(token: string): ParsedFieldEnvelope | null;
77
+ /** True when a value is a well-formed `arc.v1` envelope. */
78
+ declare function isFieldEnvelope(value: unknown): value is string;
79
+ /**
80
+ * Build the field-mode decryptor. Keys are validated eagerly (fail-fast at
81
+ * boot, not on the first sensitive response) and imported into Web Crypto
82
+ * once per `kid`, then cached.
83
+ */
84
+ declare function createFieldDecryption(options: FieldDecryptionOptions): FieldDecryption;
85
+ //#endregion
86
+ export { FIELD_ENVELOPE_PREFIX, FieldDecryption, FieldDecryptionOptions, ParsedFieldEnvelope, createFieldDecryption, isFieldEnvelope, parseFieldEnvelope };
@@ -0,0 +1,159 @@
1
+ //#region src/field-encryption.ts
2
+ /**
3
+ * `@classytic/arc-next/field-encryption` — client decrypt for arc's
4
+ * FIELD-mode ALE (`@classytic/arc/encryption` with `mode: 'fields'`).
5
+ *
6
+ * Field-mode responses stay `application/json`; only the configured field
7
+ * VALUES arrive as authenticated `arc.v1` envelopes
8
+ * (`arc.v1.<b64url(kid)>.<b64url(iv)>.<b64url(ct)>.<b64url(tag)>`,
9
+ * AES-256-GCM). This helper parses and decrypts those envelopes with the
10
+ * shared symmetric key, via Web Crypto — zero dependencies, works in Node
11
+ * 22+, Bun, Deno, and React Native (with a Web Crypto polyfill).
12
+ *
13
+ * ── SECURITY: trusted runtimes ONLY ─────────────────────────────────────
14
+ * Field mode is SYMMETRIC — whoever holds the key can decrypt every
15
+ * envelope ever produced under it. That key belongs in a Node BFF, a
16
+ * server component, or a native app's secure storage. It must NEVER ship
17
+ * in a browser bundle: anything readable by browser JavaScript is readable
18
+ * by every visitor. For payloads a browser must decrypt, use the
19
+ * asymmetric JWE path (`@classytic/arc-next/encryption`) instead — that is
20
+ * exactly why this lives in its own subpath the web bundle never imports.
21
+ * ────────────────────────────────────────────────────────────────────────
22
+ *
23
+ * Fail-closed: unknown `kid`, tampered ciphertext, or a malformed
24
+ * `arc.v1.*` token throws — an encrypted field is never silently passed
25
+ * through as ciphertext or dropped.
26
+ *
27
+ * @example Node BFF / server component
28
+ * ```ts
29
+ * import { createFieldDecryption } from '@classytic/arc-next/field-encryption';
30
+ *
31
+ * const ale = createFieldDecryption({
32
+ * // 32-byte AES-256 keys by kid — keep the previous kid during rotation.
33
+ * keys: { 'k-2026-07': keyBytes },
34
+ * });
35
+ *
36
+ * configureClient({
37
+ * baseUrl: env.ARC_URL,
38
+ * afterResponse: async (ctx) => {
39
+ * // arc stamps `x-encrypted: true` on field-mode responses — only
40
+ * // those are scanned; every other response passes through untouched.
41
+ * if (ctx.response.headers.get('x-encrypted') === 'true') {
42
+ * ctx.body = await ale.decryptFieldsDeep(ctx.body);
43
+ * }
44
+ * return ctx;
45
+ * },
46
+ * });
47
+ * ```
48
+ */
49
+ /** Version prefix shared with `@classytic/arc/encryption`'s field cipher. */
50
+ const FIELD_ENVELOPE_PREFIX = "arc.v1";
51
+ const ENVELOPE_PARTS = 6;
52
+ const KEY_BYTES = 32;
53
+ const decoder = new TextDecoder();
54
+ function b64urlToBytes(part) {
55
+ const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
56
+ const padded = b64.padEnd(Math.ceil(b64.length / 4) * 4, "=");
57
+ const bin = atob(padded);
58
+ const out = new Uint8Array(bin.length);
59
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
60
+ return out;
61
+ }
62
+ /** Parse an `arc.v1` envelope, or `null` when the token isn't one. */
63
+ function parseFieldEnvelope(token) {
64
+ if (typeof token !== "string") return null;
65
+ const parts = token.split(".");
66
+ if (parts.length !== ENVELOPE_PARTS) return null;
67
+ const [v0, v1, kidPart, ivPart, ctPart, tagPart] = parts;
68
+ if (`${v0}.${v1}` !== "arc.v1") return null;
69
+ if (!kidPart || !ivPart || !ctPart || !tagPart) return null;
70
+ try {
71
+ return {
72
+ kid: decoder.decode(b64urlToBytes(kidPart)),
73
+ iv: b64urlToBytes(ivPart),
74
+ ciphertext: b64urlToBytes(ctPart),
75
+ tag: b64urlToBytes(tagPart)
76
+ };
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+ /** True when a value LOOKS like an envelope (prefix match — cheap scan gate). */
82
+ function hasEnvelopePrefix(value) {
83
+ return typeof value === "string" && value.startsWith(`${"arc.v1"}.`);
84
+ }
85
+ /** True when a value is a well-formed `arc.v1` envelope. */
86
+ function isFieldEnvelope(value) {
87
+ return hasEnvelopePrefix(value) && parseFieldEnvelope(value) !== null;
88
+ }
89
+ /**
90
+ * Build the field-mode decryptor. Keys are validated eagerly (fail-fast at
91
+ * boot, not on the first sensitive response) and imported into Web Crypto
92
+ * once per `kid`, then cached.
93
+ */
94
+ function createFieldDecryption(options) {
95
+ const kids = Object.keys(options.keys);
96
+ if (kids.length === 0) throw new Error("[arc-next/field-encryption] at least one key is required.");
97
+ for (const kid of kids) {
98
+ const key = options.keys[kid];
99
+ if (!(key instanceof Uint8Array) || key.length !== KEY_BYTES) throw new Error(`[arc-next/field-encryption] key '${kid}' must be a ${KEY_BYTES}-byte Uint8Array (AES-256).`);
100
+ }
101
+ const imported = /* @__PURE__ */ new Map();
102
+ function keyFor(kid) {
103
+ let p = imported.get(kid);
104
+ if (!p) {
105
+ const raw = options.keys[kid];
106
+ if (!raw) throw new Error(`[arc-next/field-encryption] no key for kid '${kid}' — keep the previous kid configured during rotation so in-flight envelopes still decrypt.`);
107
+ p = crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["decrypt"]);
108
+ imported.set(kid, p);
109
+ }
110
+ return p;
111
+ }
112
+ async function decryptField(token) {
113
+ const envelope = parseFieldEnvelope(token);
114
+ if (!envelope) throw new Error("[arc-next/field-encryption] malformed arc.v1 envelope.");
115
+ const key = await keyFor(envelope.kid);
116
+ const combined = new Uint8Array(envelope.ciphertext.length + envelope.tag.length);
117
+ combined.set(envelope.ciphertext);
118
+ combined.set(envelope.tag, envelope.ciphertext.length);
119
+ try {
120
+ const plaintext = await crypto.subtle.decrypt({
121
+ name: "AES-GCM",
122
+ iv: envelope.iv
123
+ }, key, combined);
124
+ return decoder.decode(plaintext);
125
+ } catch {
126
+ throw new Error(`[arc-next/field-encryption] decryption failed for kid '${envelope.kid}' — tampered ciphertext or wrong key.`);
127
+ }
128
+ }
129
+ async function walk(node) {
130
+ if (Array.isArray(node)) {
131
+ for (let i = 0; i < node.length; i++) {
132
+ const value = node[i];
133
+ if (hasEnvelopePrefix(value)) node[i] = await decryptField(value);
134
+ else await walk(value);
135
+ }
136
+ return;
137
+ }
138
+ if (node !== null && typeof node === "object") {
139
+ const record = node;
140
+ for (const prop of Object.keys(record)) {
141
+ const value = record[prop];
142
+ if (hasEnvelopePrefix(value)) record[prop] = await decryptField(value);
143
+ else await walk(value);
144
+ }
145
+ }
146
+ }
147
+ async function decryptFieldsDeep(data) {
148
+ if (hasEnvelopePrefix(data)) return await decryptField(data);
149
+ await walk(data);
150
+ return data;
151
+ }
152
+ return {
153
+ decryptField,
154
+ decryptFieldsDeep
155
+ };
156
+ }
157
+
158
+ //#endregion
159
+ export { FIELD_ENVELOPE_PREFIX, createFieldDecryption, isFieldEnvelope, parseFieldEnvelope };
package/dist/hooks.d.ts CHANGED
@@ -21,7 +21,7 @@ import { QueryKey, UseQueryResult } from "@tanstack/react-query";
21
21
  * any cast — yet a vanilla `createCrudApi('todos')` instance has none of them
22
22
  * in autocomplete unless you opt in.
23
23
  */
24
- type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, 'getAll' | 'getById' | 'create' | 'update' | 'delete'> & {
24
+ type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, 'getAll' | 'getById' | 'create' | 'update' | 'delete' | 'count'> & {
25
25
  upload?: BaseApi<T, TCreate, TUpdate>['upload'];
26
26
  dispatchAction?: BaseApi<T, TCreate, TUpdate>['dispatchAction'];
27
27
  invokeRoute?: BaseApi<T, TCreate, TUpdate>['invokeRoute']; /** Declared aggregations (arc 2.13+). Always available on BaseApi. */
@@ -89,6 +89,14 @@ interface CrudHooksConfig<T, TCreate = Partial<T>, TUpdate = Partial<T>> {
89
89
  gcTime?: number;
90
90
  refetchOnWindowFocus?: boolean;
91
91
  structuralSharing?: boolean;
92
+ /**
93
+ * Declare this resource's read endpoints PUBLIC (`allowPublic` on the
94
+ * server — e.g. a storefront catalog). Read hooks then enable token-less
95
+ * requests by default, so callers never have to pass `{ public: true }`
96
+ * per hook. Leave unset for auth-gated resources (cart, orders, account) —
97
+ * they keep the bearer token-gate. An explicit per-call `public` wins.
98
+ */
99
+ defaultPublic?: boolean;
92
100
  messages?: {
93
101
  createSuccess?: string;
94
102
  createError?: string;
@@ -199,6 +207,12 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
199
207
  useActions: () => CrudActions<T, TCreate, TUpdate>;
200
208
  useBulkActions: () => BulkActions<T, TCreate>;
201
209
  useDeleted: (params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
210
+ /** Count-only query via arc's `?_count=true` dispatch verb — zero documents fetched. */
211
+ useCount: (params?: Record<string, unknown>, options?: {
212
+ enabled?: boolean;
213
+ staleTime?: number;
214
+ gcTime?: number;
215
+ }) => UseQueryResult<number, Error>;
202
216
  useDetailBySlug: (slug: string | null, options?: DetailQueryOptions<T>) => DetailQueryResult<T>;
203
217
  useTree: (params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
204
218
  useChildren: (parentId: string | null, params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;