@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 +14 -0
- package/dist/api.d.ts +44 -0
- package/dist/api.js +66 -0
- package/dist/cache.d.ts +15 -1
- package/dist/cache.js +21 -1
- package/dist/client.d.ts +40 -1
- package/dist/client.js +30 -1
- package/dist/field-encryption.d.ts +86 -0
- package/dist/field-encryption.js +159 -0
- package/dist/hooks.d.ts +15 -1
- package/dist/hooks.js +38 -28
- package/dist/mutation.js +4 -2
- package/dist/prefetch.d.ts +23 -62
- package/dist/prefetch.js +25 -103
- package/dist/presets/history.d.ts +56 -0
- package/dist/presets/history.js +29 -0
- package/dist/query-client.js +2 -1
- package/dist/query-options.d.ts +165 -0
- package/dist/query-options.js +188 -0
- package/llms.txt +22 -0
- package/package.json +178 -165
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>;
|