@abloatai/ablo 0.7.0 → 0.8.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/CHANGELOG.md +32 -0
- package/README.md +54 -45
- package/dist/BaseSyncedStore.js +7 -3
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +3 -2
- package/dist/auth/index.js +39 -11
- package/dist/client/Ablo.d.ts +111 -3
- package/dist/client/Ablo.js +143 -10
- package/dist/client/ApiClient.d.ts +32 -0
- package/dist/client/ApiClient.js +76 -44
- package/dist/client/auth.d.ts +11 -1
- package/dist/client/auth.js +21 -2
- package/dist/client/createModelProxy.d.ts +107 -63
- package/dist/client/createModelProxy.js +65 -33
- package/dist/client/identity.js +14 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +57 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/errorCodes.d.ts +23 -1
- package/dist/errorCodes.js +34 -1
- package/dist/errors.d.ts +52 -1
- package/dist/errors.js +140 -42
- package/dist/index.d.ts +9 -5
- package/dist/index.js +9 -5
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +25 -0
- package/dist/react/AbloProvider.js +97 -2
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/serialize.d.ts +3 -3
- package/dist/schema/serialize.js +2 -2
- package/dist/sync/BootstrapHelper.js +46 -27
- package/dist/sync/ConnectionManager.d.ts +3 -1
- package/dist/sync/ConnectionManager.js +37 -1
- package/dist/sync/HydrationCoordinator.js +3 -2
- package/dist/sync/NetworkProbe.d.ts +8 -0
- package/dist/sync/NetworkProbe.js +24 -2
- package/dist/sync/SyncWebSocket.d.ts +1 -1
- package/dist/sync/SyncWebSocket.js +43 -53
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -1
- package/docs/api-keys.md +5 -5
- package/docs/api.md +101 -44
- package/docs/audit.md +16 -9
- package/docs/cli.md +27 -17
- package/docs/client-behavior.md +34 -20
- package/docs/coordination.md +40 -51
- package/docs/data-sources.md +21 -19
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +27 -16
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +42 -27
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +26 -17
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +52 -52
- package/docs/interaction-model.md +38 -26
- package/docs/mcp/claude-code.md +9 -17
- package/docs/mcp/cursor.md +6 -24
- package/docs/mcp/windsurf.md +6 -19
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +31 -39
- package/docs/react.md +15 -11
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +109 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +6 -2
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +27 -16
- package/package.json +6 -1
package/dist/errorCodes.js
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* code, a changed HTTP status, an envelope field. Emitted in `errors.json`
|
|
37
37
|
* and on the `Ablo-Version` response header so a consumer can detect drift.
|
|
38
38
|
*/
|
|
39
|
-
export const ERROR_CONTRACT_VERSION = '2026-05-
|
|
39
|
+
export const ERROR_CONTRACT_VERSION = '2026-05-29';
|
|
40
40
|
const wire = (category, httpStatus, retryable, message) => ({ category, surface: 'wire', httpStatus, retryable, message });
|
|
41
41
|
const client = (category, message) => ({ category, surface: 'client', retryable: false, message });
|
|
42
42
|
/**
|
|
@@ -53,11 +53,30 @@ export const ERROR_CODES = {
|
|
|
53
53
|
capability_id_missing: wire('auth', 401, false, 'A capability id was expected but not provided.'),
|
|
54
54
|
exchange_failed: wire('auth', 401, false, 'The API-key credential exchange was rejected.'),
|
|
55
55
|
identity_resolve_failed: wire('auth', 401, false, 'Identity resolution was rejected.'),
|
|
56
|
+
auth_no_credentials: wire('auth', 401, false, 'No recognized authentication credential was presented — no API key and no bearer JWT. Send `Authorization: Bearer <token>`.'),
|
|
57
|
+
identity_missing_organization: wire('auth', 401, false, 'Authentication succeeded but resolved to no organization context.'),
|
|
56
58
|
session_expired: wire('auth', 401, false, 'The session is invalid or expired; re-authenticate.'),
|
|
59
|
+
// `jwt_invalid` is the residual fallback; the codes below split out the
|
|
60
|
+
// specific failure modes so an integrating customer can tell "I registered
|
|
61
|
+
// the wrong JWKS" from "my token has no org claim" from "wrong audience"
|
|
62
|
+
// rather than getting one opaque code for all of them.
|
|
63
|
+
jwt_invalid: wire('auth', 401, false, 'The bearer JWT could not be validated (unclassified).'),
|
|
64
|
+
jwt_malformed: wire('auth', 401, false, 'The bearer JWT is not a well-formed JWT and could not be decoded.'),
|
|
65
|
+
jwt_missing_issuer: wire('auth', 401, false, 'The bearer JWT has no `iss` (issuer) claim, so it cannot be routed to a trusted issuer.'),
|
|
66
|
+
jwt_issuer_untrusted: wire('auth', 401, false, "The bearer JWT's `iss` is not a registered trusted issuer. Register it via POST /v1/trusted-issuers, or check the token's issuer claim."),
|
|
67
|
+
jwt_signature_invalid: wire('auth', 401, false, "The bearer JWT's signature could not be verified against the issuer's JWKS (wrong key, rotated key, or forged token)."),
|
|
68
|
+
jwt_audience_mismatch: wire('auth', 401, false, "The bearer JWT's `aud` (audience) claim does not match the audience this issuer is registered with."),
|
|
69
|
+
jwt_missing_subject: wire('auth', 401, false, 'The bearer JWT has no `sub` (subject) claim to identify the user.'),
|
|
70
|
+
jwt_missing_organization: wire('auth', 401, false, 'The bearer JWT carries no organization context — neither a fixed org for the issuer nor the configured organization claim.'),
|
|
71
|
+
jwt_expired: wire('auth', 401, false, 'The bearer JWT has expired; obtain a fresh token.'),
|
|
72
|
+
jwt_org_membership_denied: wire('auth', 403, false, "The bearer JWT's subject is not an active member of the organization in its `org_id` claim (removed, suspended, or the claim does not match a membership)."),
|
|
57
73
|
file_upload_auth_required: wire('auth', 401, false, 'File upload requires an authenticated session.'),
|
|
58
74
|
browser_apikey_blocked: client('auth', 'Raw API keys must not be used from a browser context.'),
|
|
75
|
+
browser_database_url_blocked: client('auth', 'A database connection string must not be used from a browser context — it carries DB credentials.'),
|
|
76
|
+
datasource_registration_failed: client('auth', 'Failed to register the provided databaseUrl as this project data source.'),
|
|
59
77
|
// ── permission / capability (403) ──────────────────────────────────
|
|
60
78
|
capability_scope_denied: wire('capability', 403, false, "The connection's resolved scope does not cover the attempted action."),
|
|
79
|
+
issuer_register_forbidden: wire('permission', 403, false, 'Registering a trusted issuer requires a secret (sk_) API key.'),
|
|
61
80
|
capability_invalid: wire('capability', 403, false, 'The capability is unknown, revoked, or expired.'),
|
|
62
81
|
byo_role_cannot_enforce_rls: wire('permission', 403, false, 'The bring-your-own DB role cannot enforce row-level security.'),
|
|
63
82
|
byo_role_unreadable: wire('permission', 403, false, 'The bring-your-own DB role could not be introspected.'),
|
|
@@ -80,6 +99,7 @@ export const ERROR_CODES = {
|
|
|
80
99
|
commit_operation_required: wire('validation', 400, false, 'A commit must carry `operation` or `operations`.'),
|
|
81
100
|
commit_operation_model_required: wire('validation', 400, false, 'A commit operation is missing its `model`.'),
|
|
82
101
|
commit_operations_ambiguous: wire('validation', 400, false, 'A commit supplied both `operation` and `operations`.'),
|
|
102
|
+
commit_too_many_operations: wire('validation', 400, false, 'A commit exceeded the per-commit operation limit; split it into smaller batches.'),
|
|
83
103
|
model_required_field_missing: wire('validation', 400, false, 'A required field was absent from the model payload.'),
|
|
84
104
|
model_identifier_missing: wire('validation', 400, false, 'The model payload is missing its identifier.'),
|
|
85
105
|
snapshot_reserved_key: wire('validation', 400, false, 'A snapshot used a reserved key name.'),
|
|
@@ -92,6 +112,18 @@ export const ERROR_CODES = {
|
|
|
92
112
|
model_not_found: wire('not_found', 404, false, 'The referenced model row does not exist.'),
|
|
93
113
|
mutate_update_entity_not_found: wire('not_found', 404, false, 'The entity targeted by an update does not exist.'),
|
|
94
114
|
task_id_missing: wire('server', 502, true, 'The task-create response did not include an id.'),
|
|
115
|
+
// ── data integrity / DB constraints ────────────────────────────────
|
|
116
|
+
// Emitted when a write is rejected by a database integrity constraint
|
|
117
|
+
// (Postgres class-23). All NON-retryable: the same payload re-sent
|
|
118
|
+
// unchanged will fail identically, so the client must roll back, not
|
|
119
|
+
// retry. The server normalizer maps SQLSTATE → these codes and tucks the
|
|
120
|
+
// raw constraint/column/table detail into `details` rather than leaking
|
|
121
|
+
// the driver's message text onto the wire.
|
|
122
|
+
not_null_violation: wire('validation', 400, false, 'A required field was missing (database not-null constraint).'),
|
|
123
|
+
foreign_key_violation: wire('conflict', 409, false, 'A referenced entity does not exist, or is still referenced (database foreign-key constraint).'),
|
|
124
|
+
unique_violation: wire('conflict', 409, false, 'A value violates a uniqueness constraint.'),
|
|
125
|
+
check_violation: wire('validation', 400, false, 'A value violates a database check constraint.'),
|
|
126
|
+
constraint_violation: wire('validation', 400, false, 'A database integrity constraint was violated.'),
|
|
95
127
|
// ── tenant / unknown model (400) ───────────────────────────────────
|
|
96
128
|
server_execute_unknown_model: wire('tenant', 400, false, 'The server-execute request named a model not in the tenant schema.'),
|
|
97
129
|
mutate_create_unknown_model: wire('tenant', 400, false, 'A create targeted a model not in the tenant schema.'),
|
|
@@ -152,6 +184,7 @@ export const ERROR_CODES = {
|
|
|
152
184
|
turn_open_failed: wire('server', 500, true, 'The agent turn failed to open.'),
|
|
153
185
|
turn_close_failed: wire('server', 500, true, 'The agent turn failed to close cleanly.'),
|
|
154
186
|
// ── client-only invariants (never serialized) ──────────────────────
|
|
187
|
+
invalid_options: client('client', 'The Ablo client was constructed with invalid or incomplete options.'),
|
|
155
188
|
no_ablo_provider: client('client', 'An Ablo hook was used outside of an Ablo provider.'),
|
|
156
189
|
no_sync_group_provider: client('client', 'A sync-group hook was used outside of its provider.'),
|
|
157
190
|
sync_context_missing_provider: client('client', 'Sync context was read outside of its provider.'),
|
package/dist/errors.d.ts
CHANGED
|
@@ -272,11 +272,62 @@ export declare class SyncSessionError extends AbloAuthenticationError {
|
|
|
272
272
|
*/
|
|
273
273
|
static isSessionErrorResponse(status: number, body?: string): boolean;
|
|
274
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* Coerce ANY thrown value into an {@link AbloError} — the last-line guarantee
|
|
277
|
+
* that an SDK consumer never catches an untagged error. An already-typed
|
|
278
|
+
* AbloError passes through untouched (so `code`/`httpStatus`/subclass survive);
|
|
279
|
+
* a bare `Error` keeps its message and is preserved as `cause` (carrying any
|
|
280
|
+
* `.code` someone attached); a non-Error is stringified.
|
|
281
|
+
*
|
|
282
|
+
* This is the client mirror of the server's `normalizeError` — applied at the
|
|
283
|
+
* SDK's public async boundaries so `instanceof AbloError` / `e.type` always
|
|
284
|
+
* hold for whatever a consumer catches, regardless of which internal layer
|
|
285
|
+
* (transport, IndexedDB, bootstrap, a third-party throw) produced it.
|
|
286
|
+
*/
|
|
287
|
+
export declare function toAbloError(err: unknown): AbloError;
|
|
288
|
+
/**
|
|
289
|
+
* Build the appropriate typed {@link AbloError} from a wire error — the
|
|
290
|
+
* single code→class mapping shared by every transport that can reject a
|
|
291
|
+
* request (HTTP responses via {@link translateHttpError}, WebSocket
|
|
292
|
+
* `mutation_result`/`claim_ack` frames, agent-job receipts).
|
|
293
|
+
*
|
|
294
|
+
* Code-first, then status-driven. A known {@link ErrorCode} carries its own
|
|
295
|
+
* canonical `httpStatus` in the registry, so frame transports that don't have
|
|
296
|
+
* an HTTP status (the WebSocket commit path) still produce the right subclass
|
|
297
|
+
* — instead of a hand-rolled `new Error(message)` that drops out of the typed
|
|
298
|
+
* hierarchy and loses `code`/`httpStatus`/retryability.
|
|
299
|
+
*/
|
|
300
|
+
export declare function errorFromWire(message: string, opts?: {
|
|
301
|
+
code?: string;
|
|
302
|
+
/** Explicit transport status (HTTP). When omitted, derived from the
|
|
303
|
+
* registry spec for `code` so frame transports map correctly too. */
|
|
304
|
+
httpStatus?: number;
|
|
305
|
+
requestId?: string;
|
|
306
|
+
requiredCapability?: RequiredCapability;
|
|
307
|
+
}): AbloError;
|
|
275
308
|
/**
|
|
276
309
|
* Translate an HTTP response into the appropriate typed error.
|
|
277
310
|
*
|
|
278
311
|
* Single source of truth for status-code → class mapping — every SDK
|
|
279
312
|
* fetch path that sees a non-2xx response should route through here
|
|
280
|
-
* so the customer-visible error is always the right subclass.
|
|
313
|
+
* so the customer-visible error is always the right subclass. Delegates
|
|
314
|
+
* the actual class selection to {@link errorFromWire} (shared with the
|
|
315
|
+
* frame transports) after extracting code/message from the HTTP body.
|
|
281
316
|
*/
|
|
282
317
|
export declare function translateHttpError(status: number, body: unknown, requestId?: string): AbloError;
|
|
318
|
+
/**
|
|
319
|
+
* Whether an HTTP error body carries a code {@link translateHttpError} can read
|
|
320
|
+
* — a top-level `code`, a nested `error.code`, or a string `error`. Callers that
|
|
321
|
+
* own a meaningful fallback code (e.g. `turn_open_failed`) use this to decide
|
|
322
|
+
* between routing through `translateHttpError` (structured envelope present) and
|
|
323
|
+
* throwing their own typed error with the fallback (bare/non-Ablo body), instead
|
|
324
|
+
* of emitting a code-less error.
|
|
325
|
+
*/
|
|
326
|
+
export declare function hasWireCode(body: unknown): boolean;
|
|
327
|
+
/**
|
|
328
|
+
* Extract the canonical error `code` from a raw HTTP error body STRING — the
|
|
329
|
+
* top-level `code` or a nested `error.code`. Returns undefined for non-JSON or
|
|
330
|
+
* code-less bodies. Used by session-error detection to tell a genuine expiry
|
|
331
|
+
* (`session_expired`/`jwt_expired`) apart from other auth failures.
|
|
332
|
+
*/
|
|
333
|
+
export declare function extractWireCode(body?: string): string | undefined;
|
package/dist/errors.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Both work on every subclass.
|
|
20
20
|
*/
|
|
21
|
+
import { errorCodeSpec } from './errorCodes.js';
|
|
21
22
|
export { ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode } from './errorCodes.js';
|
|
22
23
|
// ── AbloError hierarchy — the typed error surface ────────────────────
|
|
23
24
|
/** Common shape for all errors thrown by this SDK. */
|
|
@@ -237,29 +238,106 @@ export class SyncSessionError extends AbloAuthenticationError {
|
|
|
237
238
|
* Check if an HTTP response status indicates a session error
|
|
238
239
|
*/
|
|
239
240
|
static isSessionErrorResponse(status, body) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return true;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
return true;
|
|
241
|
+
// Prefer the structured error code: ONLY a genuine session / JWT EXPIRY
|
|
242
|
+
// should drive sign-out + re-auth. A non-expiry auth failure
|
|
243
|
+
// (api_key_required, jwt_issuer_untrusted, a 403 permission denial, …) must
|
|
244
|
+
// NOT — re-authenticating re-mints the same credential and loops, which is
|
|
245
|
+
// exactly the "flash then bounce to /signin" symptom. This mirrors the
|
|
246
|
+
// canonical wire mapping (401 → Authentication, 403 → Permission).
|
|
247
|
+
const code = extractWireCode(body);
|
|
248
|
+
if (code) {
|
|
249
|
+
return code === 'session_expired' || code === 'jwt_expired';
|
|
253
250
|
}
|
|
254
|
-
|
|
251
|
+
// No structured code (bare body, non-Ablo proxy response): a 401 is taken as
|
|
252
|
+
// expiry — the historical default that drives re-auth — while a 403 is a
|
|
253
|
+
// permission failure, not a session error.
|
|
254
|
+
return status === 401;
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Coerce ANY thrown value into an {@link AbloError} — the last-line guarantee
|
|
259
|
+
* that an SDK consumer never catches an untagged error. An already-typed
|
|
260
|
+
* AbloError passes through untouched (so `code`/`httpStatus`/subclass survive);
|
|
261
|
+
* a bare `Error` keeps its message and is preserved as `cause` (carrying any
|
|
262
|
+
* `.code` someone attached); a non-Error is stringified.
|
|
263
|
+
*
|
|
264
|
+
* This is the client mirror of the server's `normalizeError` — applied at the
|
|
265
|
+
* SDK's public async boundaries so `instanceof AbloError` / `e.type` always
|
|
266
|
+
* hold for whatever a consumer catches, regardless of which internal layer
|
|
267
|
+
* (transport, IndexedDB, bootstrap, a third-party throw) produced it.
|
|
268
|
+
*/
|
|
269
|
+
export function toAbloError(err) {
|
|
270
|
+
if (err instanceof AbloError)
|
|
271
|
+
return err;
|
|
272
|
+
if (err instanceof Error) {
|
|
273
|
+
const rawCode = err.code;
|
|
274
|
+
const code = typeof rawCode === 'string' ? rawCode : undefined;
|
|
275
|
+
return new AbloError(err.message, { code, cause: err });
|
|
276
|
+
}
|
|
277
|
+
return new AbloError(String(err), { cause: err });
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Build the appropriate typed {@link AbloError} from a wire error — the
|
|
281
|
+
* single code→class mapping shared by every transport that can reject a
|
|
282
|
+
* request (HTTP responses via {@link translateHttpError}, WebSocket
|
|
283
|
+
* `mutation_result`/`claim_ack` frames, agent-job receipts).
|
|
284
|
+
*
|
|
285
|
+
* Code-first, then status-driven. A known {@link ErrorCode} carries its own
|
|
286
|
+
* canonical `httpStatus` in the registry, so frame transports that don't have
|
|
287
|
+
* an HTTP status (the WebSocket commit path) still produce the right subclass
|
|
288
|
+
* — instead of a hand-rolled `new Error(message)` that drops out of the typed
|
|
289
|
+
* hierarchy and loses `code`/`httpStatus`/retryability.
|
|
290
|
+
*/
|
|
291
|
+
export function errorFromWire(message, opts = {}) {
|
|
292
|
+
const { code, requestId, requiredCapability } = opts;
|
|
293
|
+
// Effective status: an explicit HTTP status wins; otherwise fall back to
|
|
294
|
+
// the code's canonical status from the registry (undefined for unknown /
|
|
295
|
+
// forward-compat codes, which then map to the base AbloError).
|
|
296
|
+
const httpStatus = opts.httpStatus ?? (code ? errorCodeSpec(code)?.httpStatus : undefined);
|
|
297
|
+
// Wire boundary: an incoming code is an arbitrary string (a newer server
|
|
298
|
+
// may send a code this SDK predates). Cast to ErrorCode here — the one
|
|
299
|
+
// sanctioned crossing — so internal producers stay statically checked.
|
|
300
|
+
const publicCode = (code === 'intent_conflict' ? 'claim_conflict' : code);
|
|
301
|
+
const baseOpts = { code: publicCode, httpStatus, requestId };
|
|
302
|
+
// ── Code-first specials (transport-independent) ──────────────────────
|
|
303
|
+
// A scoped credential was denied — route through CapabilityError so callers
|
|
304
|
+
// can read `.requiredCapability` to attenuate-and-retry.
|
|
305
|
+
if (code === 'capability_scope_denied' || code === 'capability_invalid') {
|
|
306
|
+
return new CapabilityError(code, message, requiredCapability);
|
|
307
|
+
}
|
|
308
|
+
// Claim enforcement (rides 409): the target entity is held by another
|
|
309
|
+
// participant. Discriminate on code BEFORE the generic 409→idempotency
|
|
310
|
+
// mapping so a claim rejection surfaces as AbloClaimedError.
|
|
311
|
+
if (code === 'intent_conflict' || code === 'claim_conflict' || code === 'entity_claimed') {
|
|
312
|
+
return new AbloClaimedError(message, baseOpts);
|
|
313
|
+
}
|
|
314
|
+
// A write whose `readAt` watermark went stale — callers re-read and retry.
|
|
315
|
+
if (code === 'stale_context') {
|
|
316
|
+
return new AbloStaleContextError(message, baseOpts);
|
|
317
|
+
}
|
|
318
|
+
// ── Status-driven dispatch (HTTP parity) ─────────────────────────────
|
|
319
|
+
if (httpStatus === 401)
|
|
320
|
+
return new AbloAuthenticationError(message, baseOpts);
|
|
321
|
+
if (httpStatus === 403)
|
|
322
|
+
return new AbloPermissionError(message, baseOpts);
|
|
323
|
+
if (httpStatus === 409)
|
|
324
|
+
return new AbloIdempotencyError(message, baseOpts);
|
|
325
|
+
if (httpStatus === 422 || httpStatus === 400)
|
|
326
|
+
return new AbloValidationError(message, baseOpts);
|
|
327
|
+
if (httpStatus === 429)
|
|
328
|
+
return new AbloRateLimitError(message, baseOpts);
|
|
329
|
+
if (httpStatus !== undefined && httpStatus >= 500)
|
|
330
|
+
return new AbloServerError(message, baseOpts);
|
|
331
|
+
return new AbloError(message, baseOpts);
|
|
332
|
+
}
|
|
257
333
|
/**
|
|
258
334
|
* Translate an HTTP response into the appropriate typed error.
|
|
259
335
|
*
|
|
260
336
|
* Single source of truth for status-code → class mapping — every SDK
|
|
261
337
|
* fetch path that sees a non-2xx response should route through here
|
|
262
|
-
* so the customer-visible error is always the right subclass.
|
|
338
|
+
* so the customer-visible error is always the right subclass. Delegates
|
|
339
|
+
* the actual class selection to {@link errorFromWire} (shared with the
|
|
340
|
+
* frame transports) after extracting code/message from the HTTP body.
|
|
263
341
|
*/
|
|
264
342
|
export function translateHttpError(status, body, requestId) {
|
|
265
343
|
const parsed = typeof body === 'object' && body !== null ? body : {};
|
|
@@ -274,33 +352,53 @@ export function translateHttpError(status, body, requestId) {
|
|
|
274
352
|
flatError ??
|
|
275
353
|
(typeof body === 'string' ? body : `HTTP ${status}`);
|
|
276
354
|
const requiredCapability = nested?.requiredCapability ?? parsed.requiredCapability;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
355
|
+
return errorFromWire(message, { code, httpStatus: status, requestId, requiredCapability });
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Whether an HTTP error body carries a code {@link translateHttpError} can read
|
|
359
|
+
* — a top-level `code`, a nested `error.code`, or a string `error`. Callers that
|
|
360
|
+
* own a meaningful fallback code (e.g. `turn_open_failed`) use this to decide
|
|
361
|
+
* between routing through `translateHttpError` (structured envelope present) and
|
|
362
|
+
* throwing their own typed error with the fallback (bare/non-Ablo body), instead
|
|
363
|
+
* of emitting a code-less error.
|
|
364
|
+
*/
|
|
365
|
+
export function hasWireCode(body) {
|
|
366
|
+
if (typeof body !== 'object' || body === null)
|
|
367
|
+
return false;
|
|
368
|
+
const b = body;
|
|
369
|
+
if (typeof b.code === 'string')
|
|
370
|
+
return true;
|
|
371
|
+
if (typeof b.error === 'string')
|
|
372
|
+
return true;
|
|
373
|
+
return (typeof b.error === 'object' &&
|
|
374
|
+
b.error !== null &&
|
|
375
|
+
typeof b.error.code === 'string');
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Extract the canonical error `code` from a raw HTTP error body STRING — the
|
|
379
|
+
* top-level `code` or a nested `error.code`. Returns undefined for non-JSON or
|
|
380
|
+
* code-less bodies. Used by session-error detection to tell a genuine expiry
|
|
381
|
+
* (`session_expired`/`jwt_expired`) apart from other auth failures.
|
|
382
|
+
*/
|
|
383
|
+
export function extractWireCode(body) {
|
|
384
|
+
if (!body)
|
|
385
|
+
return undefined;
|
|
386
|
+
let parsed;
|
|
387
|
+
try {
|
|
388
|
+
parsed = JSON.parse(body);
|
|
289
389
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
// claim rejection surfaces as AbloClaimedError, not AbloIdempotencyError —
|
|
293
|
-
// same typed error the WebSocket commit path yields for these codes.
|
|
294
|
-
if (code === 'intent_conflict' || code === 'claim_conflict' || code === 'entity_claimed') {
|
|
295
|
-
return new AbloClaimedError(message, baseOpts);
|
|
390
|
+
catch {
|
|
391
|
+
return undefined;
|
|
296
392
|
}
|
|
297
|
-
if (
|
|
298
|
-
return
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
393
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
394
|
+
return undefined;
|
|
395
|
+
const b = parsed;
|
|
396
|
+
if (typeof b.code === 'string')
|
|
397
|
+
return b.code;
|
|
398
|
+
if (typeof b.error === 'object' &&
|
|
399
|
+
b.error !== null &&
|
|
400
|
+
typeof b.error.code === 'string') {
|
|
401
|
+
return b.error.code;
|
|
402
|
+
}
|
|
403
|
+
return undefined;
|
|
306
404
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* import Ablo from '@abloatai/ablo';
|
|
6
6
|
*
|
|
7
7
|
* const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
8
|
-
* await ablo.weatherReports.
|
|
8
|
+
* const report = await ablo.weatherReports.retrieve('report_stockholm');
|
|
9
9
|
* await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
|
|
10
10
|
*
|
|
11
11
|
* type Entry = Ablo.Peer;
|
|
@@ -23,9 +23,13 @@
|
|
|
23
23
|
* @abloatai/ablo/react — <AbloProvider>, useQuery, useMutate
|
|
24
24
|
* @abloatai/ablo/testing — test harnesses + mocks
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Reads split by where the data comes from. `ablo.<model>.retrieve(id)` and
|
|
27
|
+
* `.list({ where })` are the async **server** reads (pool → IDB → network via
|
|
28
|
+
* the `HydrationCoordinator`, single-flight deduped); they're the default and
|
|
29
|
+
* what hosted/stateless callers want, since their local graph starts empty.
|
|
30
|
+
* `ablo.<model>.get(id)` / `.getAll(...)` / `.getCount(...)` are synchronous
|
|
31
|
+
* **local-graph** snapshots with no network round-trip — for reactive React
|
|
32
|
+
* selectors (`useAblo((ablo) => ablo.<model>.get(id))`) once the graph is warm.
|
|
29
33
|
*
|
|
30
34
|
* ── What to import (read this first) ────────────────────────────────
|
|
31
35
|
* Default path — this is all most apps and agents ever need:
|
|
@@ -49,7 +53,7 @@ import { Ablo } from './client/Ablo.js';
|
|
|
49
53
|
export default Ablo;
|
|
50
54
|
export { dataSource, abloSource, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
|
|
51
55
|
export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
|
|
52
|
-
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
|
|
56
|
+
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
|
|
53
57
|
export type { CommitReceipt, RequiredCapability } from './errors.js';
|
|
54
58
|
export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec } from './errors.js';
|
|
55
59
|
export type { Register, DefaultSyncShape } from './types/global.js';
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* import Ablo from '@abloatai/ablo';
|
|
6
6
|
*
|
|
7
7
|
* const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
8
|
-
* await ablo.weatherReports.
|
|
8
|
+
* const report = await ablo.weatherReports.retrieve('report_stockholm');
|
|
9
9
|
* await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
|
|
10
10
|
*
|
|
11
11
|
* type Entry = Ablo.Peer;
|
|
@@ -23,9 +23,13 @@
|
|
|
23
23
|
* @abloatai/ablo/react — <AbloProvider>, useQuery, useMutate
|
|
24
24
|
* @abloatai/ablo/testing — test harnesses + mocks
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Reads split by where the data comes from. `ablo.<model>.retrieve(id)` and
|
|
27
|
+
* `.list({ where })` are the async **server** reads (pool → IDB → network via
|
|
28
|
+
* the `HydrationCoordinator`, single-flight deduped); they're the default and
|
|
29
|
+
* what hosted/stateless callers want, since their local graph starts empty.
|
|
30
|
+
* `ablo.<model>.get(id)` / `.getAll(...)` / `.getCount(...)` are synchronous
|
|
31
|
+
* **local-graph** snapshots with no network round-trip — for reactive React
|
|
32
|
+
* selectors (`useAblo((ablo) => ablo.<model>.get(id))`) once the graph is warm.
|
|
29
33
|
*
|
|
30
34
|
* ── What to import (read this first) ────────────────────────────────
|
|
31
35
|
* Default path — this is all most apps and agents ever need:
|
|
@@ -78,7 +82,7 @@ export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
|
|
|
78
82
|
// Typed error hierarchy — Stripe-style. One import gets every class
|
|
79
83
|
// consumers need to discriminate failures (`e instanceof AbloX` or
|
|
80
84
|
// `e.type === 'AbloX'`) plus the HTTP-response translator.
|
|
81
|
-
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
|
|
85
|
+
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
|
|
82
86
|
// Advanced — most apps never import this. Custom (Zero-style) mutators:
|
|
83
87
|
// `ablo.<model>.create/update/delete` already covers normal writes. Reach
|
|
84
88
|
// for `defineMutators` only when you need a named, multi-step mutation with
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Ablo API-key format — the single source of truth for how keys
|
|
3
|
+
* are minted, hashed, and validated. Both the sync-server (`apiKeyStore`)
|
|
4
|
+
* and the web control-plane (`generate-key.ts`) consume THIS module, so the
|
|
5
|
+
* format can no longer drift between the two mint sites (it used to live as
|
|
6
|
+
* a hand-copied twin kept in sync by a comment).
|
|
7
|
+
*
|
|
8
|
+
* Node-only — uses `node:crypto`. Exposed via the `@abloatai/ablo/keys`
|
|
9
|
+
* subpath and NEVER re-exported from the browser-facing `.` entry, so the
|
|
10
|
+
* client bundle never pulls in `node:crypto`.
|
|
11
|
+
*
|
|
12
|
+
* Format (GitHub-style): `<sk|rk|ek>_<live|test>_<30 base62 body><6-char
|
|
13
|
+
* base62 CRC32 checksum>`. The identifiable prefix + CRC32 checksum let
|
|
14
|
+
* secret scanners detect leaks and let us reject typo'd/forged keys OFFLINE
|
|
15
|
+
* (no DB round-trip). Legacy keys (a ~43-char base64url body, no checksum)
|
|
16
|
+
* still validate by hash — they parse here as `checksummed: false`.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
export declare const API_KEY_KINDS: readonly ["secret", "restricted", "ephemeral"];
|
|
20
|
+
export type ApiKeyKind = (typeof API_KEY_KINDS)[number];
|
|
21
|
+
export declare const API_KEY_ENVS: readonly ["live", "test"];
|
|
22
|
+
export type ApiKeyEnv = (typeof API_KEY_ENVS)[number];
|
|
23
|
+
/** A structurally-valid Ablo API key, parsed into its parts. */
|
|
24
|
+
export interface ParsedApiKey {
|
|
25
|
+
/** The original plaintext. */
|
|
26
|
+
raw: string;
|
|
27
|
+
kind: ApiKeyKind;
|
|
28
|
+
env: ApiKeyEnv;
|
|
29
|
+
/** The chars after `<prefix>_<env>_` (body + checksum for new keys). */
|
|
30
|
+
body: string;
|
|
31
|
+
/** True when this is the new checksummed format (36-char base62 body). */
|
|
32
|
+
checksummed: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Canonical schema for an Ablo API key. `parse`/`safeParse` returns a typed
|
|
36
|
+
* {@link ParsedApiKey}; a new checksummed-format key with a BAD checksum is
|
|
37
|
+
* rejected (the offline-reject), while a legacy key parses as
|
|
38
|
+
* `checksummed: false` and passes (the server still hash-validates it).
|
|
39
|
+
*/
|
|
40
|
+
export declare const apiKeySchema: z.ZodPipe<z.ZodString, z.ZodTransform<ParsedApiKey, string>>;
|
|
41
|
+
/** Parse + fully validate (incl. checksum). Returns null when invalid. */
|
|
42
|
+
export declare function parseApiKey(raw: string): ParsedApiKey | null;
|
|
43
|
+
/** True when the key uses the new checksummed format (regardless of validity). */
|
|
44
|
+
export declare function isChecksummedKey(raw: string): boolean;
|
|
45
|
+
/** Verify the embedded checksum. Meaningful only for checksummed-format keys. */
|
|
46
|
+
export declare function keyChecksumMatches(raw: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Mint a key: `<prefix>_<env>_<body><checksum>`. Returns the plaintext (shown
|
|
49
|
+
* once), its SHA-256 hash (persisted), and the 12-char display prefix.
|
|
50
|
+
*/
|
|
51
|
+
export declare function generateApiKey(env?: ApiKeyEnv, kind?: ApiKeyKind): {
|
|
52
|
+
plaintext: string;
|
|
53
|
+
hash: string;
|
|
54
|
+
prefix: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Stable SHA-256 hex of a plaintext key. A fast hash is CORRECT here (not
|
|
58
|
+
* bcrypt) — API keys are high-entropy random, so there's no dictionary to
|
|
59
|
+
* defend against. Used at both write (mint) and lookup.
|
|
60
|
+
*/
|
|
61
|
+
export declare function hashApiKey(plaintext: string): string;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Ablo API-key format — the single source of truth for how keys
|
|
3
|
+
* are minted, hashed, and validated. Both the sync-server (`apiKeyStore`)
|
|
4
|
+
* and the web control-plane (`generate-key.ts`) consume THIS module, so the
|
|
5
|
+
* format can no longer drift between the two mint sites (it used to live as
|
|
6
|
+
* a hand-copied twin kept in sync by a comment).
|
|
7
|
+
*
|
|
8
|
+
* Node-only — uses `node:crypto`. Exposed via the `@abloatai/ablo/keys`
|
|
9
|
+
* subpath and NEVER re-exported from the browser-facing `.` entry, so the
|
|
10
|
+
* client bundle never pulls in `node:crypto`.
|
|
11
|
+
*
|
|
12
|
+
* Format (GitHub-style): `<sk|rk|ek>_<live|test>_<30 base62 body><6-char
|
|
13
|
+
* base62 CRC32 checksum>`. The identifiable prefix + CRC32 checksum let
|
|
14
|
+
* secret scanners detect leaks and let us reject typo'd/forged keys OFFLINE
|
|
15
|
+
* (no DB round-trip). Legacy keys (a ~43-char base64url body, no checksum)
|
|
16
|
+
* still validate by hash — they parse here as `checksummed: false`.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
// ── Vocabulary ──────────────────────────────────────────────────────────
|
|
21
|
+
// The three-key Stripe model:
|
|
22
|
+
// secret (sk_) — backend / server-to-server / agents. Full authority. Never in a browser.
|
|
23
|
+
// restricted (rk_) — scoped SERVER key (agent session tokens / capabilities).
|
|
24
|
+
// ephemeral (ek_) — short-lived, backend-minted, USER-scoped BROWSER session credential
|
|
25
|
+
// (Stripe ephemeral keys). Carries participantKind:'user' + baked syncGroups.
|
|
26
|
+
// (There is no publishable `pk_` — the minted session token, not a project
|
|
27
|
+
// identifier, is what the browser holds; it already names the org.)
|
|
28
|
+
export const API_KEY_KINDS = ['secret', 'restricted', 'ephemeral'];
|
|
29
|
+
export const API_KEY_ENVS = ['live', 'test'];
|
|
30
|
+
const PREFIX_BY_KIND = {
|
|
31
|
+
secret: 'sk',
|
|
32
|
+
restricted: 'rk',
|
|
33
|
+
ephemeral: 'ek',
|
|
34
|
+
};
|
|
35
|
+
const KIND_BY_PREFIX = {
|
|
36
|
+
sk: 'secret',
|
|
37
|
+
rk: 'restricted',
|
|
38
|
+
ek: 'ephemeral',
|
|
39
|
+
};
|
|
40
|
+
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
41
|
+
/** Random base62 chars before the checksum. */
|
|
42
|
+
const KEY_BODY_LEN = 30;
|
|
43
|
+
/** base62(CRC32): 62^6 (~5.7e10) > 2^32, so a CRC32 always fits in 6 chars. */
|
|
44
|
+
const CHECKSUM_LEN = 6;
|
|
45
|
+
/** A new checksummed body is exactly this long and pure base62. */
|
|
46
|
+
const CHECKSUMMED_BODY_LEN = KEY_BODY_LEN + CHECKSUM_LEN;
|
|
47
|
+
/** `<sk|rk|ek>_<live|test>_<body>`; body charset covers base62 AND legacy base64url. */
|
|
48
|
+
const KEY_RE = /^(sk|rk|ek)_(live|test)_([0-9A-Za-z\-_]+)$/;
|
|
49
|
+
const BASE62_RE = /^[0-9A-Za-z]+$/;
|
|
50
|
+
// ── Checksum (standard CRC-32, GitHub-compatible) ───────────────────────
|
|
51
|
+
const CRC32_TABLE = (() => {
|
|
52
|
+
const t = new Uint32Array(256);
|
|
53
|
+
for (let n = 0; n < 256; n++) {
|
|
54
|
+
let c = n;
|
|
55
|
+
for (let k = 0; k < 8; k++)
|
|
56
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
57
|
+
t[n] = c >>> 0;
|
|
58
|
+
}
|
|
59
|
+
return t;
|
|
60
|
+
})();
|
|
61
|
+
function crc32(s) {
|
|
62
|
+
let c = 0xffffffff;
|
|
63
|
+
for (let i = 0; i < s.length; i++) {
|
|
64
|
+
c = (CRC32_TABLE[(c ^ s.charCodeAt(i)) & 0xff] ^ (c >>> 8)) >>> 0;
|
|
65
|
+
}
|
|
66
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
67
|
+
}
|
|
68
|
+
/** 6-char base62 encoding of the CRC32 of `payload`. */
|
|
69
|
+
function checksum6(payload) {
|
|
70
|
+
let n = crc32(payload);
|
|
71
|
+
let out = '';
|
|
72
|
+
for (let i = 0; i < CHECKSUM_LEN; i++) {
|
|
73
|
+
out = BASE62[n % 62] + out;
|
|
74
|
+
n = Math.floor(n / 62);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** `len` cryptographically-random base62 chars (rejection-sampled, no bias). */
|
|
79
|
+
function randomBase62(len) {
|
|
80
|
+
let out = '';
|
|
81
|
+
while (out.length < len) {
|
|
82
|
+
for (const b of randomBytes(len * 2)) {
|
|
83
|
+
if (b < 248) {
|
|
84
|
+
out += BASE62[b % 62];
|
|
85
|
+
if (out.length === len)
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
function bodyIsChecksummed(body) {
|
|
93
|
+
return body.length === CHECKSUMMED_BODY_LEN && BASE62_RE.test(body);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Canonical schema for an Ablo API key. `parse`/`safeParse` returns a typed
|
|
97
|
+
* {@link ParsedApiKey}; a new checksummed-format key with a BAD checksum is
|
|
98
|
+
* rejected (the offline-reject), while a legacy key parses as
|
|
99
|
+
* `checksummed: false` and passes (the server still hash-validates it).
|
|
100
|
+
*/
|
|
101
|
+
export const apiKeySchema = z.string().transform((raw, ctx) => {
|
|
102
|
+
const m = KEY_RE.exec(raw);
|
|
103
|
+
if (!m) {
|
|
104
|
+
ctx.addIssue({ code: 'custom', message: 'not a valid Ablo API key format' });
|
|
105
|
+
return z.NEVER;
|
|
106
|
+
}
|
|
107
|
+
const [, prefix, env, body] = m;
|
|
108
|
+
const checksummed = bodyIsChecksummed(body);
|
|
109
|
+
if (checksummed && checksum6(raw.slice(0, -CHECKSUM_LEN)) !== body.slice(KEY_BODY_LEN)) {
|
|
110
|
+
ctx.addIssue({ code: 'custom', message: 'API key checksum mismatch' });
|
|
111
|
+
return z.NEVER;
|
|
112
|
+
}
|
|
113
|
+
return { raw, kind: KIND_BY_PREFIX[prefix], env: env, body, checksummed };
|
|
114
|
+
});
|
|
115
|
+
// ── Derived validators (thin wrappers over the same spec) ───────────────
|
|
116
|
+
/** Parse + fully validate (incl. checksum). Returns null when invalid. */
|
|
117
|
+
export function parseApiKey(raw) {
|
|
118
|
+
const r = apiKeySchema.safeParse(raw);
|
|
119
|
+
return r.success ? r.data : null;
|
|
120
|
+
}
|
|
121
|
+
/** True when the key uses the new checksummed format (regardless of validity). */
|
|
122
|
+
export function isChecksummedKey(raw) {
|
|
123
|
+
const m = KEY_RE.exec(raw);
|
|
124
|
+
return m !== null && bodyIsChecksummed(m[3]);
|
|
125
|
+
}
|
|
126
|
+
/** Verify the embedded checksum. Meaningful only for checksummed-format keys. */
|
|
127
|
+
export function keyChecksumMatches(raw) {
|
|
128
|
+
const m = KEY_RE.exec(raw);
|
|
129
|
+
if (!m || !bodyIsChecksummed(m[3]))
|
|
130
|
+
return false;
|
|
131
|
+
return checksum6(raw.slice(0, -CHECKSUM_LEN)) === m[3].slice(KEY_BODY_LEN);
|
|
132
|
+
}
|
|
133
|
+
// ── Mint + hash (node:crypto) ───────────────────────────────────────────
|
|
134
|
+
/**
|
|
135
|
+
* Mint a key: `<prefix>_<env>_<body><checksum>`. Returns the plaintext (shown
|
|
136
|
+
* once), its SHA-256 hash (persisted), and the 12-char display prefix.
|
|
137
|
+
*/
|
|
138
|
+
export function generateApiKey(env = 'live', kind = 'secret') {
|
|
139
|
+
const body = randomBase62(KEY_BODY_LEN);
|
|
140
|
+
const payload = `${PREFIX_BY_KIND[kind]}_${env}_${body}`;
|
|
141
|
+
const plaintext = `${payload}${checksum6(payload)}`;
|
|
142
|
+
return { plaintext, hash: hashApiKey(plaintext), prefix: plaintext.slice(0, 12) };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Stable SHA-256 hex of a plaintext key. A fast hash is CORRECT here (not
|
|
146
|
+
* bcrypt) — API keys are high-entropy random, so there's no dictionary to
|
|
147
|
+
* defend against. Used at both write (mint) and lookup.
|
|
148
|
+
*/
|
|
149
|
+
export function hashApiKey(plaintext) {
|
|
150
|
+
return createHash('sha256').update(plaintext).digest('hex');
|
|
151
|
+
}
|