@abloatai/ablo 0.6.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 +77 -0
- package/README.md +95 -57
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +8 -4
- 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 +112 -3
- package/dist/client/Ablo.js +144 -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 +120 -53
- package/dist/client/createModelProxy.js +66 -31
- 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/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +286 -0
- package/dist/errorCodes.js +284 -0
- package/dist/errors.d.ts +103 -7
- package/dist/errors.js +192 -41
- package/dist/index.d.ts +11 -6
- package/dist/index.js +10 -6
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +37 -0
- package/dist/react/AbloProvider.js +107 -4
- 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/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +6 -0
- package/dist/schema/diff.js +21 -3
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/index.d.ts +7 -4
- package/dist/schema/index.js +9 -3
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +2 -112
- package/dist/schema/schema.js +50 -62
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +16 -12
- package/dist/schema/serialize.js +16 -12
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- 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.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +26 -19
- 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/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +10 -16
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +47 -3
- package/docs/api.md +103 -57
- package/docs/audit.md +16 -9
- package/docs/cli.md +222 -0
- package/docs/client-behavior.md +35 -21
- package/docs/coordination.md +74 -36
- package/docs/data-sources.md +23 -21
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +30 -19
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +93 -0
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +29 -17
- package/docs/identity.md +198 -121
- package/docs/index.md +35 -18
- package/docs/integration-guide.md +79 -83
- package/docs/interaction-model.md +40 -25
- 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 +18 -14
- package/docs/roadmap.md +15 -3
- 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 +13 -1
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -88,6 +88,21 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
88
88
|
* usually don't pass this explicitly server-side.
|
|
89
89
|
*/
|
|
90
90
|
apiKey?: string | ApiKeySetter | null | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Connection string to YOUR OWN Postgres. When set, Ablo registers this
|
|
93
|
+
* database as your project's data store and writes synced rows back into it
|
|
94
|
+
* (dedicated/BYO tenant), so your data stays canonical in your DB while Ablo
|
|
95
|
+
* runs the sync/coordination plane. Defaults to `process.env['DATABASE_URL']`.
|
|
96
|
+
*
|
|
97
|
+
* SERVER-ONLY: this carries credentials, so it is never sent from the browser
|
|
98
|
+
* — constructing a client with `databaseUrl` and `dangerouslyAllowBrowser`
|
|
99
|
+
* throws. Provide Ablo a NON-superuser, non-`BYPASSRLS` role: the server runs
|
|
100
|
+
* the tenant plane with row-level security forced, and rejects a privileged
|
|
101
|
+
* role that couldn't enforce isolation.
|
|
102
|
+
*
|
|
103
|
+
* Omit it to use Ablo-managed storage (the hosted default).
|
|
104
|
+
*/
|
|
105
|
+
databaseUrl?: string | null | undefined;
|
|
91
106
|
/**
|
|
92
107
|
* Local persistence mode. Pass `indexeddb` only when you want offline
|
|
93
108
|
* queueing and a reload-surviving browser cache.
|
|
@@ -112,8 +127,8 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
112
127
|
defaultQuery?: Record<string, string | undefined> | undefined;
|
|
113
128
|
/**
|
|
114
129
|
* Client-side use is disabled by default because private API keys should
|
|
115
|
-
* not ship to browsers. Set this only when
|
|
116
|
-
*
|
|
130
|
+
* not ship to browsers. Set this only when the browser holds a minted
|
|
131
|
+
* session token (`ek_`/`rk_`) or you route through a controlled server proxy.
|
|
117
132
|
*/
|
|
118
133
|
dangerouslyAllowBrowser?: boolean | undefined;
|
|
119
134
|
}
|
|
@@ -168,7 +183,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
168
183
|
* Client-side use of this SDK is disabled by default — your apiKey
|
|
169
184
|
* would ship to every visitor's network tab. Only set this to
|
|
170
185
|
* `true` if you've understood the risk and have appropriate
|
|
171
|
-
* mitigations (a
|
|
186
|
+
* mitigations (a minted session token, a server-side proxy, etc).
|
|
172
187
|
*/
|
|
173
188
|
dangerouslyAllowBrowser?: boolean | undefined;
|
|
174
189
|
/**
|
|
@@ -459,6 +474,72 @@ export interface ModelClient<T = Record<string, unknown>> {
|
|
|
459
474
|
update(id: string, data: Record<string, unknown>, options?: ModelMutationOptions): Promise<CommitReceipt>;
|
|
460
475
|
delete(id: string, options?: ModelMutationOptions): Promise<CommitReceipt>;
|
|
461
476
|
}
|
|
477
|
+
/** A single data operation a scoped **agent** session may perform on a model. */
|
|
478
|
+
export type SessionOperation = 'read' | 'create' | 'update' | 'delete';
|
|
479
|
+
/** Mint params for an **end-user** session — full data authority within the
|
|
480
|
+
* org (the Stripe `ephemeralKeys.create` / Supabase session shape). Mints an
|
|
481
|
+
* `ek_` token. `user.id` is your end user's external IdP id (becomes the
|
|
482
|
+
* session's `participantId`); Ablo does not model your users, so it's an
|
|
483
|
+
* honest string at the trust boundary. */
|
|
484
|
+
export interface CreateUserSessionParams {
|
|
485
|
+
/** Your end user. `id` becomes the token's `participantId`. */
|
|
486
|
+
user: {
|
|
487
|
+
id: string;
|
|
488
|
+
};
|
|
489
|
+
/** Sync groups this session may subscribe to. Omit to inherit the key's scope. */
|
|
490
|
+
syncGroups?: readonly string[];
|
|
491
|
+
/** Token lifetime in seconds. Defaults to 900 (15m, the Stripe ephemeral default). */
|
|
492
|
+
ttlSeconds?: number;
|
|
493
|
+
/** Opaque identity blob echoed back to the client as `ablo.user`. */
|
|
494
|
+
userMeta?: Record<string, unknown>;
|
|
495
|
+
agent?: never;
|
|
496
|
+
can?: never;
|
|
497
|
+
}
|
|
498
|
+
/** Mint params for a scoped **agent** session — mints a restricted `rk_` token
|
|
499
|
+
* gated to exactly the operations named in `can`. `can` is typed off your
|
|
500
|
+
* schema (no magic `'task.update'` strings): `{ Task: ['update'], Deck: ['read'] }`
|
|
501
|
+
* — the SDK serializes each entry to the wire allowlist (`task.update`). */
|
|
502
|
+
export interface CreateAgentSessionParams<S extends SchemaRecord> {
|
|
503
|
+
/** Your agent. `id` becomes the token's `participantId`. */
|
|
504
|
+
agent: {
|
|
505
|
+
id: string;
|
|
506
|
+
};
|
|
507
|
+
/** Per-model operation allowlist, typed against the schema's model names. */
|
|
508
|
+
can: {
|
|
509
|
+
[M in keyof S & string]?: readonly SessionOperation[];
|
|
510
|
+
};
|
|
511
|
+
/** Sync groups this session may subscribe to. Omit to inherit the key's scope. */
|
|
512
|
+
syncGroups?: readonly string[];
|
|
513
|
+
/** Token lifetime in seconds. Defaults to 900 (15m, the Stripe ephemeral default). */
|
|
514
|
+
ttlSeconds?: number;
|
|
515
|
+
/** Opaque identity blob echoed back to the client as `ablo.agent`. */
|
|
516
|
+
userMeta?: Record<string, unknown>;
|
|
517
|
+
user?: never;
|
|
518
|
+
}
|
|
519
|
+
/** Params for {@link Ablo.sessions}.create — a discriminated union: pass
|
|
520
|
+
* `{ user }` for a full-authority end-user session (`ek_`) or `{ agent, can }`
|
|
521
|
+
* for a scoped agent session (`rk_`). */
|
|
522
|
+
export type CreateSessionParams<S extends SchemaRecord> = CreateUserSessionParams | CreateAgentSessionParams<S>;
|
|
523
|
+
/** A minted end-user session token — the Stripe ephemeral-key / Supabase
|
|
524
|
+
* session resource. `token` is the secret the browser presents as its bearer. */
|
|
525
|
+
export interface AbloSession {
|
|
526
|
+
object: 'session';
|
|
527
|
+
/** Stable id of the minted credential (for revocation). */
|
|
528
|
+
id: string;
|
|
529
|
+
/** The short-lived `rk_` session token. Hand this to the user's browser. */
|
|
530
|
+
token: string;
|
|
531
|
+
/** ISO-8601 expiry. */
|
|
532
|
+
expiresAt: string;
|
|
533
|
+
organizationId: string;
|
|
534
|
+
scope: {
|
|
535
|
+
organizationId: string;
|
|
536
|
+
syncGroups: readonly string[];
|
|
537
|
+
operations: readonly string[];
|
|
538
|
+
participantKind: 'user' | 'agent' | 'system';
|
|
539
|
+
participantId: string;
|
|
540
|
+
};
|
|
541
|
+
userMeta: Record<string, unknown>;
|
|
542
|
+
}
|
|
462
543
|
/** The typed sync engine client — one property per model in the schema */
|
|
463
544
|
export type Ablo<S extends SchemaRecord> = {
|
|
464
545
|
readonly [K in keyof S & string]: ModelOperations<InferModel<Schema<S>, K>, InferCreate<Schema<S>, K>>;
|
|
@@ -502,6 +583,31 @@ export type Ablo<S extends SchemaRecord> = {
|
|
|
502
583
|
waitForFlush(timeoutMs?: number): Promise<void>;
|
|
503
584
|
/** Disconnect and clean up */
|
|
504
585
|
dispose(): Promise<void>;
|
|
586
|
+
/**
|
|
587
|
+
* Replace the bearer auth token used for the WebSocket upgrade and HTTP
|
|
588
|
+
* requests, WITHOUT tearing down the engine. Use to push a refreshed
|
|
589
|
+
* short-lived token (e.g. a 15m JWT) before it expires — `<AbloProvider>`'s
|
|
590
|
+
* `getToken` refresh loop calls this. Reuses the same rotation path as the
|
|
591
|
+
* internal capability-token refresh; safe to call before `ready()`.
|
|
592
|
+
*/
|
|
593
|
+
setAuthToken(token: string): void;
|
|
594
|
+
/**
|
|
595
|
+
* Mint a short-lived, scoped **session token** for one end user — the
|
|
596
|
+
* Stripe `ephemeralKeys.create` / Supabase session shape. Call this on YOUR
|
|
597
|
+
* BACKEND (where the `sk_` secret key lives), then hand the returned
|
|
598
|
+
* `token` to that user's browser (typically via an authEndpoint the client
|
|
599
|
+
* fetches). The browser presents it as the bearer; the sync-server verifies
|
|
600
|
+
* the scoped `rk_` token via `apiKeyProvider`.
|
|
601
|
+
*
|
|
602
|
+
* The browser must NEVER see the `sk_` key — only the per-user session token.
|
|
603
|
+
*
|
|
604
|
+
* Pass `{ user: { id } }` for a full-authority end-user session (mints `ek_`),
|
|
605
|
+
* or `{ agent: { id }, can: { Task: ['update'] } }` for a scoped agent
|
|
606
|
+
* session (mints `rk_`); `can` is typed against your schema's model names.
|
|
607
|
+
*/
|
|
608
|
+
sessions: {
|
|
609
|
+
create(params: CreateSessionParams<S>): Promise<AbloSession>;
|
|
610
|
+
};
|
|
505
611
|
/**
|
|
506
612
|
* Destroy every IndexedDB database owned by this engine. Disconnects
|
|
507
613
|
* the WebSocket, releases timers, and deletes all `ablo_*` / `ablo-*`
|
|
@@ -766,6 +872,8 @@ export declare namespace Ablo {
|
|
|
766
872
|
type CapabilityRecord = import('./ApiClient.js').CapabilityRecord;
|
|
767
873
|
type CapabilityResource = import('./ApiClient.js').CapabilityResource;
|
|
768
874
|
type CapabilityRevocation = import('./ApiClient.js').CapabilityRevocation;
|
|
875
|
+
type CapabilityRotateOptions = import('./ApiClient.js').CapabilityRotateOptions;
|
|
876
|
+
type RotatedCapability = import('./ApiClient.js').RotatedCapability;
|
|
769
877
|
type Task = import('./ApiClient.js').Task;
|
|
770
878
|
type TaskCreateOptions = import('./ApiClient.js').TaskCreateOptions;
|
|
771
879
|
type TaskCloseOptions = import('./ApiClient.js').TaskCloseOptions;
|
|
@@ -784,6 +892,7 @@ export declare namespace Ablo {
|
|
|
784
892
|
type ActiveIntent = _Streams.ActiveIntent;
|
|
785
893
|
type Claim = _Streams.Claim;
|
|
786
894
|
type IntentRejection = _Streams.IntentRejection;
|
|
895
|
+
type IntentLost = _Streams.IntentLost;
|
|
787
896
|
type Snapshot<TSchema extends _SchemaTypes.Schema = _SchemaTypes.Schema, K extends keyof TSchema['models'] = keyof TSchema['models']> = _Streams.Snapshot<TSchema, K>;
|
|
788
897
|
type Turn = import('./Ablo.js').Turn;
|
|
789
898
|
namespace Auth {
|
package/dist/client/Ablo.js
CHANGED
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
* await sync.reports.delete(reportId);
|
|
17
17
|
*/
|
|
18
18
|
import { z } from 'zod';
|
|
19
|
-
import { AbloClaimedError, AbloError, AbloConnectionError, AbloValidationError, translateHttpError } from '../errors.js';
|
|
19
|
+
import { AbloClaimedError, AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, translateHttpError, hasWireCode, toAbloError } from '../errors.js';
|
|
20
20
|
import { LoadStrategy, PropertyType } from '../types/index.js';
|
|
21
21
|
import { initSyncEngine } from '../context.js';
|
|
22
22
|
import { noopObservability, browserOnlineStatus, defaultSessionErrorDetector, noopAnalytics, } from '../SyncEngineContext.js';
|
|
23
23
|
import { alwaysOnline } from '../adapters/alwaysOnline.js';
|
|
24
24
|
import { validateAbloOptions } from './validateAbloOptions.js';
|
|
25
|
+
import { exchangeApiKey } from '../auth/index.js';
|
|
25
26
|
import { createInternalComponents } from './createInternalComponents.js';
|
|
26
27
|
import { resolveParticipantIdentity } from './identity.js';
|
|
27
28
|
import { Model } from '../Model.js';
|
|
@@ -32,7 +33,8 @@ import { awaitIntentGrant } from '../sync/awaitIntentGrant.js';
|
|
|
32
33
|
import { createSnapshot } from '../sync/createSnapshot.js';
|
|
33
34
|
import { createParticipantManager } from '../sync/participants.js';
|
|
34
35
|
import { createProtocolClient, } from './ApiClient.js';
|
|
35
|
-
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveAuthToken, resolveBaseURL, } from './auth.js';
|
|
36
|
+
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, } from './auth.js';
|
|
37
|
+
import { registerDataSource } from './registerDataSource.js';
|
|
36
38
|
import { shouldUseInMemoryPersistence, } from './persistence.js';
|
|
37
39
|
import { createModelProxy } from './createModelProxy.js';
|
|
38
40
|
// ── Config derivation from schema ─────────────────────────────────────────
|
|
@@ -653,8 +655,10 @@ export function Ablo(options) {
|
|
|
653
655
|
const authInput = { options, env };
|
|
654
656
|
const configuredApiKey = resolveApiKey(authInput);
|
|
655
657
|
const configuredAuthToken = resolveAuthToken(authInput);
|
|
658
|
+
const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
|
|
656
659
|
assertBrowserSafety({
|
|
657
660
|
apiKey: configuredApiKey,
|
|
661
|
+
databaseUrl: configuredDatabaseUrl,
|
|
658
662
|
dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
|
|
659
663
|
});
|
|
660
664
|
const { logger = consoleLogger } = internalOptions;
|
|
@@ -827,6 +831,19 @@ export function Ablo(options) {
|
|
|
827
831
|
}
|
|
828
832
|
_readyPromise = (async () => {
|
|
829
833
|
try {
|
|
834
|
+
// Register the caller's own database for write-back BEFORE bootstrap, so
|
|
835
|
+
// the server resolves this org's data plane to the customer's DB rather
|
|
836
|
+
// than serving an empty/wrong store. The org is derived server-side from
|
|
837
|
+
// the API key. Idempotent server-side (register-or-update). Skipped when
|
|
838
|
+
// no `databaseUrl` was configured (Ablo-managed storage).
|
|
839
|
+
if (configuredDatabaseUrl) {
|
|
840
|
+
await registerDataSource({
|
|
841
|
+
baseUrl: resolveBootstrapBaseUrl({ url }),
|
|
842
|
+
apiKey: await resolveApiKeyValue(configuredApiKey),
|
|
843
|
+
databaseUrl: configuredDatabaseUrl,
|
|
844
|
+
...(internalOptions.fetch ? { fetchImpl: internalOptions.fetch } : {}),
|
|
845
|
+
});
|
|
846
|
+
}
|
|
830
847
|
// Resolve participant identity + scope. Three branches —
|
|
831
848
|
// hosted-cloud apiKey exchange, self-derived from capability
|
|
832
849
|
// token, or legacy explicit options. See `./identity.ts`.
|
|
@@ -836,7 +853,15 @@ export function Ablo(options) {
|
|
|
836
853
|
url,
|
|
837
854
|
kind,
|
|
838
855
|
configuredApiKey,
|
|
839
|
-
|
|
856
|
+
// Resolve identity against the LIVE token, not the construction-time
|
|
857
|
+
// `configuredAuthToken`. Consumers using `getToken` (apps/web) never
|
|
858
|
+
// pass `authToken` at construction — they call `setAuthToken()` before
|
|
859
|
+
// `ready()`, which updates `currentCapabilityToken`. Reading the frozen
|
|
860
|
+
// `configuredAuthToken` here made `/auth/identity` fire with no Bearer
|
|
861
|
+
// (→ `no_matching_provider` / `session_expired`) even though the JWT
|
|
862
|
+
// was present. Mirrors `authHeaders()`'s `currentCapabilityToken ??
|
|
863
|
+
// configuredAuthToken` precedence.
|
|
864
|
+
configuredAuthToken: currentCapabilityToken ?? configuredAuthToken,
|
|
840
865
|
bootstrapHelper,
|
|
841
866
|
logger,
|
|
842
867
|
applyRotatedToken: (token) => {
|
|
@@ -902,7 +927,11 @@ export function Ablo(options) {
|
|
|
902
927
|
}
|
|
903
928
|
const result = current.value;
|
|
904
929
|
if (!result.success) {
|
|
905
|
-
throw result.error
|
|
930
|
+
throw result.error
|
|
931
|
+
? toAbloError(result.error)
|
|
932
|
+
: new AbloConnectionError('Sync engine initialization failed', {
|
|
933
|
+
code: 'bootstrap_fetch_timeout',
|
|
934
|
+
});
|
|
906
935
|
}
|
|
907
936
|
// Wire presence + intents to the now-open transport.
|
|
908
937
|
// `getSyncWebSocket()` returns non-null after a successful
|
|
@@ -916,11 +945,23 @@ export function Ablo(options) {
|
|
|
916
945
|
logger.info('Sync engine ready', { models: Object.keys(schema.models).length });
|
|
917
946
|
}
|
|
918
947
|
catch (err) {
|
|
919
|
-
|
|
948
|
+
// Coerce so the rejection a consumer awaiting `ready()` catches is
|
|
949
|
+
// always an AbloError — connection setup is held to the same
|
|
950
|
+
// never-leak-untagged contract as the model operations.
|
|
951
|
+
const error = toAbloError(err);
|
|
920
952
|
// Make sure syncStatus reflects the failure for observer() components
|
|
921
953
|
store.syncStatus.state = 'error';
|
|
922
954
|
store.syncStatus.error = error;
|
|
923
|
-
|
|
955
|
+
// Log the typed envelope (type + code + status), not just the bare
|
|
956
|
+
// message — so the console line names it as an Ablo error and carries
|
|
957
|
+
// the code (e.g. AbloAuthenticationError/identity_resolve_failed on a
|
|
958
|
+
// 401) instead of reading like an untagged failure.
|
|
959
|
+
logger.error('Sync engine failed to initialize', {
|
|
960
|
+
type: error.type,
|
|
961
|
+
code: error.code,
|
|
962
|
+
httpStatus: error.httpStatus,
|
|
963
|
+
error: error.message,
|
|
964
|
+
});
|
|
924
965
|
throw error;
|
|
925
966
|
}
|
|
926
967
|
})();
|
|
@@ -1211,6 +1252,7 @@ export function Ablo(options) {
|
|
|
1211
1252
|
entities: { [modelKey]: id },
|
|
1212
1253
|
}),
|
|
1213
1254
|
queue: (target) => publicIntents.queueFor({ type: target.model, id: target.id }),
|
|
1255
|
+
reorder: (target, order) => publicIntents.reorder({ type: target.model, id: target.id }, order),
|
|
1214
1256
|
observe: (target) => {
|
|
1215
1257
|
// The live intent stream only tracks *open* (active) claims;
|
|
1216
1258
|
// terminal states (committed / expired / canceled) drop out of
|
|
@@ -1383,6 +1425,68 @@ export function Ablo(options) {
|
|
|
1383
1425
|
...modelProxies,
|
|
1384
1426
|
ready,
|
|
1385
1427
|
waitForFlush,
|
|
1428
|
+
setAuthToken(token) {
|
|
1429
|
+
// Same rotation path as the internal capability-token refresh
|
|
1430
|
+
// (`applyRotatedToken` in `ready()`): update the closure binding the
|
|
1431
|
+
// HTTP hydration provider reads, push to the bootstrap helper's header,
|
|
1432
|
+
// and swap it on the live WebSocket. Decoupled from `ready()` so a
|
|
1433
|
+
// refreshed JWT can be pushed at any point in the engine's lifetime.
|
|
1434
|
+
currentCapabilityToken = token;
|
|
1435
|
+
bootstrapHelper.setAuthToken(token);
|
|
1436
|
+
store.getSyncWebSocket()?.setCapabilityToken(token);
|
|
1437
|
+
},
|
|
1438
|
+
sessions: {
|
|
1439
|
+
// Stripe `ephemeralKeys.create` shape: a BACKEND (holding `sk_`) mints a
|
|
1440
|
+
// short-lived scoped token for one end user OR one agent. Thin wrapper over
|
|
1441
|
+
// the `/auth/capability` exchange, reshaped to a Stripe-style resource.
|
|
1442
|
+
async create(params) {
|
|
1443
|
+
const apiKey = await resolveApiKeyValue(configuredApiKey);
|
|
1444
|
+
if (!apiKey) {
|
|
1445
|
+
throw new AbloAuthenticationError('sessions.create requires a secret (sk_) API key — call it from your backend, not the browser.', { code: 'apikey_missing' });
|
|
1446
|
+
}
|
|
1447
|
+
const baseUrl = resolveBootstrapBaseUrl({
|
|
1448
|
+
url,
|
|
1449
|
+
bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
|
|
1450
|
+
});
|
|
1451
|
+
// Discriminate the union: `{ user }` → full-authority `ek_` (no op
|
|
1452
|
+
// allowlist); `{ agent, can }` → scoped `rk_`. `can: { Task: ['update'] }`
|
|
1453
|
+
// serializes to the wire allowlist `['task.update']` — the Hub matches
|
|
1454
|
+
// `${model.toLowerCase()}.${op}` (Hub.ts handleCommit).
|
|
1455
|
+
let participantKind;
|
|
1456
|
+
let participantId;
|
|
1457
|
+
let operations;
|
|
1458
|
+
if (params.user) {
|
|
1459
|
+
participantKind = 'user';
|
|
1460
|
+
participantId = params.user.id;
|
|
1461
|
+
operations = undefined;
|
|
1462
|
+
}
|
|
1463
|
+
else {
|
|
1464
|
+
participantKind = 'agent';
|
|
1465
|
+
participantId = params.agent.id;
|
|
1466
|
+
operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
|
|
1467
|
+
}
|
|
1468
|
+
const res = await exchangeApiKey({
|
|
1469
|
+
apiKey,
|
|
1470
|
+
baseUrl,
|
|
1471
|
+
participantKind,
|
|
1472
|
+
participantId,
|
|
1473
|
+
...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
|
|
1474
|
+
...(operations ? { operations } : {}),
|
|
1475
|
+
ttlSeconds: params.ttlSeconds ?? 900,
|
|
1476
|
+
...(params.userMeta ? { userMeta: params.userMeta } : {}),
|
|
1477
|
+
...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
|
|
1478
|
+
});
|
|
1479
|
+
return {
|
|
1480
|
+
object: 'session',
|
|
1481
|
+
id: res.capabilityId,
|
|
1482
|
+
token: res.token,
|
|
1483
|
+
expiresAt: res.expiresAt,
|
|
1484
|
+
organizationId: res.organizationId,
|
|
1485
|
+
scope: res.scope,
|
|
1486
|
+
userMeta: res.userMeta,
|
|
1487
|
+
};
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1386
1490
|
async dispose() {
|
|
1387
1491
|
_refreshScheduler?.dispose();
|
|
1388
1492
|
_refreshScheduler = null;
|
|
@@ -1490,8 +1594,24 @@ export function Ablo(options) {
|
|
|
1490
1594
|
}),
|
|
1491
1595
|
});
|
|
1492
1596
|
if (!res.ok) {
|
|
1493
|
-
const
|
|
1494
|
-
|
|
1597
|
+
const text = await res.text().catch(() => '');
|
|
1598
|
+
let parsed = text;
|
|
1599
|
+
if (text) {
|
|
1600
|
+
try {
|
|
1601
|
+
parsed = JSON.parse(text);
|
|
1602
|
+
}
|
|
1603
|
+
catch {
|
|
1604
|
+
/* keep raw text */
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
// Preserve the server's structured envelope (code/message/doc_url) when
|
|
1608
|
+
// present; fall back to turn_open_failed for a bare/non-Ablo body.
|
|
1609
|
+
throw hasWireCode(parsed)
|
|
1610
|
+
? translateHttpError(res.status, parsed, res.headers.get('x-request-id') ?? undefined)
|
|
1611
|
+
: new AbloError(`beginTurn failed: ${res.status} ${text}`, {
|
|
1612
|
+
code: 'turn_open_failed',
|
|
1613
|
+
httpStatus: res.status,
|
|
1614
|
+
});
|
|
1495
1615
|
}
|
|
1496
1616
|
const json = (await res.json());
|
|
1497
1617
|
const turnId = json.turnId;
|
|
@@ -1514,8 +1634,22 @@ export function Ablo(options) {
|
|
|
1514
1634
|
}),
|
|
1515
1635
|
});
|
|
1516
1636
|
if (!closeRes.ok) {
|
|
1517
|
-
const
|
|
1518
|
-
|
|
1637
|
+
const text = await closeRes.text().catch(() => '');
|
|
1638
|
+
let parsed = text;
|
|
1639
|
+
if (text) {
|
|
1640
|
+
try {
|
|
1641
|
+
parsed = JSON.parse(text);
|
|
1642
|
+
}
|
|
1643
|
+
catch {
|
|
1644
|
+
/* keep raw text */
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
throw hasWireCode(parsed)
|
|
1648
|
+
? translateHttpError(closeRes.status, parsed, closeRes.headers.get('x-request-id') ?? undefined)
|
|
1649
|
+
: new AbloError(`closeTurn failed: ${closeRes.status} ${text}`, {
|
|
1650
|
+
code: 'turn_close_failed',
|
|
1651
|
+
httpStatus: closeRes.status,
|
|
1652
|
+
});
|
|
1519
1653
|
}
|
|
1520
1654
|
};
|
|
1521
1655
|
const dispose = () => {
|
|
@@ -73,10 +73,42 @@ export interface CapabilityRevocation {
|
|
|
73
73
|
readonly deleted: boolean;
|
|
74
74
|
readonly activeSessionsClosed?: number;
|
|
75
75
|
}
|
|
76
|
+
export interface CapabilityRotateOptions {
|
|
77
|
+
/**
|
|
78
|
+
* Overlap window — the OLD token keeps authenticating for this long after
|
|
79
|
+
* rotation, so you can deploy the replacement with zero downtime. Default
|
|
80
|
+
* 24h server-side.
|
|
81
|
+
*/
|
|
82
|
+
readonly grace?: Duration;
|
|
83
|
+
readonly graceSeconds?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Lifetime of the REPLACEMENT capability. Omit to inherit the original's
|
|
86
|
+
* lifetime.
|
|
87
|
+
*/
|
|
88
|
+
readonly lease?: Duration;
|
|
89
|
+
readonly leaseSeconds?: number;
|
|
90
|
+
}
|
|
91
|
+
/** The fresh capability returned by `rotate`, plus a pointer to the old one. */
|
|
92
|
+
export interface RotatedCapability extends Capability {
|
|
93
|
+
/**
|
|
94
|
+
* The capability that was rotated out. Its token keeps working until
|
|
95
|
+
* `expiresAt` (the end of the grace window), then expires.
|
|
96
|
+
*/
|
|
97
|
+
readonly rotatedFrom: {
|
|
98
|
+
readonly id: string;
|
|
99
|
+
readonly expiresAt: string;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
76
102
|
export interface CapabilityResource {
|
|
77
103
|
create(options: CapabilityCreateOptions): Promise<Capability>;
|
|
78
104
|
retrieve(id: string): Promise<CapabilityRecord>;
|
|
79
105
|
revoke(id: string): Promise<CapabilityRevocation>;
|
|
106
|
+
/**
|
|
107
|
+
* Rotate with overlap (Stripe's "roll" model): mint a fresh capability
|
|
108
|
+
* carrying the SAME scope, and keep the old token working for a grace
|
|
109
|
+
* window so you can roll out the replacement without downtime.
|
|
110
|
+
*/
|
|
111
|
+
rotate(id: string, options?: CapabilityRotateOptions): Promise<RotatedCapability>;
|
|
80
112
|
/**
|
|
81
113
|
* Alias for `create`. Kept because "mint" is common capability-token
|
|
82
114
|
* language, but `create` is the canonical SDK verb.
|
package/dist/client/ApiClient.js
CHANGED
|
@@ -439,6 +439,35 @@ export function createProtocolClient(options) {
|
|
|
439
439
|
activeSessionsClosed: body.activeSessionsClosed,
|
|
440
440
|
};
|
|
441
441
|
},
|
|
442
|
+
async rotate(id, rotateOptions = {}) {
|
|
443
|
+
const graceSeconds = rotateOptions.graceSeconds ??
|
|
444
|
+
(rotateOptions.grace !== undefined ? toSeconds(rotateOptions.grace) : undefined);
|
|
445
|
+
const leaseSeconds = rotateOptions.leaseSeconds ??
|
|
446
|
+
(rotateOptions.lease !== undefined ? toSeconds(rotateOptions.lease) : undefined);
|
|
447
|
+
const body = await requestJson(`/v1/capabilities/${encodeURIComponent(id)}/rotate`, {
|
|
448
|
+
method: 'POST',
|
|
449
|
+
body: JSON.stringify({
|
|
450
|
+
...(graceSeconds !== undefined ? { graceSeconds } : {}),
|
|
451
|
+
...(leaseSeconds !== undefined ? { ttlSeconds: leaseSeconds } : {}),
|
|
452
|
+
}),
|
|
453
|
+
});
|
|
454
|
+
const newId = body.capabilityId ?? body.id;
|
|
455
|
+
if (!newId) {
|
|
456
|
+
throw new AbloValidationError('Capability rotate response did not include an id.', { code: 'capability_id_missing' });
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
id: newId,
|
|
460
|
+
token: body.token,
|
|
461
|
+
expiresAt: body.expiresAt,
|
|
462
|
+
organizationId: body.organizationId,
|
|
463
|
+
scope: body.scope,
|
|
464
|
+
rotatedFrom: {
|
|
465
|
+
id: body.rotatedFrom.capabilityId ?? body.rotatedFrom.id ?? id,
|
|
466
|
+
expiresAt: body.rotatedFrom.expiresAt,
|
|
467
|
+
},
|
|
468
|
+
client: () => childClient(body.token),
|
|
469
|
+
};
|
|
470
|
+
},
|
|
442
471
|
mint(options) {
|
|
443
472
|
return capabilities.create(options);
|
|
444
473
|
},
|
|
@@ -548,6 +577,50 @@ export function createProtocolClient(options) {
|
|
|
548
577
|
claims: query.claims ?? [],
|
|
549
578
|
};
|
|
550
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* Single-op mutation over the model-scoped routes — the canonical surface
|
|
582
|
+
* that mirrors `ablo.<model>.create/update/delete`:
|
|
583
|
+
*
|
|
584
|
+
* POST /v1/models/:model create
|
|
585
|
+
* PATCH /v1/models/:model/:id update
|
|
586
|
+
* DELETE /v1/models/:model/:id delete
|
|
587
|
+
*
|
|
588
|
+
* This replaces the previous indirection through `POST /v1/commits`. The raw
|
|
589
|
+
* `commits.create(...)` resource is still the path for ATOMIC MULTI-OP
|
|
590
|
+
* envelopes — this helper is the one-op, one-record path only.
|
|
591
|
+
*/
|
|
592
|
+
async function mutateModel(action, modelName, id, data, options) {
|
|
593
|
+
const clientTxId = createClientTxId(options?.idempotencyKey);
|
|
594
|
+
const encModel = encodeURIComponent(modelName);
|
|
595
|
+
const path = action === 'create'
|
|
596
|
+
? `/v1/models/${encModel}`
|
|
597
|
+
: `/v1/models/${encModel}/${encodeURIComponent(id)}`;
|
|
598
|
+
const method = action === 'create' ? 'POST' : action === 'update' ? 'PATCH' : 'DELETE';
|
|
599
|
+
const requestBody = {
|
|
600
|
+
idempotencyKey: clientTxId,
|
|
601
|
+
intent: normalizeIntentId(options?.intent),
|
|
602
|
+
onStale: options?.onStale,
|
|
603
|
+
readAt: options?.readAt,
|
|
604
|
+
};
|
|
605
|
+
if (action === 'create')
|
|
606
|
+
requestBody.id = id;
|
|
607
|
+
if (data !== undefined)
|
|
608
|
+
requestBody.data = data;
|
|
609
|
+
const body = await requestJson(path, {
|
|
610
|
+
method,
|
|
611
|
+
idempotencyKey: clientTxId,
|
|
612
|
+
body: JSON.stringify(requestBody),
|
|
613
|
+
});
|
|
614
|
+
// `requestJson` throws via `translateHttpError` on any non-2xx, so reaching
|
|
615
|
+
// here implies success. Narrow `status` to the `CommitWait`-compatible
|
|
616
|
+
// subset; `'rejected'` only appears on a thrown rejection body.
|
|
617
|
+
const status = body.status === 'queued' ? 'queued' : 'confirmed';
|
|
618
|
+
return {
|
|
619
|
+
id: body.serverTxId ?? body.id ?? body.clientTxId ?? clientTxId,
|
|
620
|
+
status,
|
|
621
|
+
lastSyncId: body.lastSyncId,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
551
624
|
function model(name) {
|
|
552
625
|
return {
|
|
553
626
|
retrieve(id, options) {
|
|
@@ -556,56 +629,15 @@ export function createProtocolClient(options) {
|
|
|
556
629
|
async create(data, mutationOptions) {
|
|
557
630
|
const id = mutationOptions?.id ?? createModelId();
|
|
558
631
|
await applyClaimedPolicy({ model: name, id }, mutationOptions);
|
|
559
|
-
return
|
|
560
|
-
intent: mutationOptions?.intent,
|
|
561
|
-
idempotencyKey: mutationOptions?.idempotencyKey,
|
|
562
|
-
readAt: mutationOptions?.readAt,
|
|
563
|
-
onStale: mutationOptions?.onStale,
|
|
564
|
-
wait: mutationOptions?.wait,
|
|
565
|
-
operations: [
|
|
566
|
-
{
|
|
567
|
-
action: 'create',
|
|
568
|
-
model: name,
|
|
569
|
-
id,
|
|
570
|
-
data,
|
|
571
|
-
},
|
|
572
|
-
],
|
|
573
|
-
});
|
|
632
|
+
return mutateModel('create', name, id, data, mutationOptions);
|
|
574
633
|
},
|
|
575
634
|
async update(id, data, mutationOptions) {
|
|
576
635
|
await applyClaimedPolicy({ model: name, id }, mutationOptions);
|
|
577
|
-
return
|
|
578
|
-
intent: mutationOptions?.intent,
|
|
579
|
-
idempotencyKey: mutationOptions?.idempotencyKey,
|
|
580
|
-
readAt: mutationOptions?.readAt,
|
|
581
|
-
onStale: mutationOptions?.onStale,
|
|
582
|
-
wait: mutationOptions?.wait,
|
|
583
|
-
operations: [
|
|
584
|
-
{
|
|
585
|
-
action: 'update',
|
|
586
|
-
model: name,
|
|
587
|
-
id,
|
|
588
|
-
data,
|
|
589
|
-
},
|
|
590
|
-
],
|
|
591
|
-
});
|
|
636
|
+
return mutateModel('update', name, id, data, mutationOptions);
|
|
592
637
|
},
|
|
593
638
|
async delete(id, mutationOptions) {
|
|
594
639
|
await applyClaimedPolicy({ model: name, id }, mutationOptions);
|
|
595
|
-
return
|
|
596
|
-
intent: mutationOptions?.intent,
|
|
597
|
-
idempotencyKey: mutationOptions?.idempotencyKey,
|
|
598
|
-
readAt: mutationOptions?.readAt,
|
|
599
|
-
onStale: mutationOptions?.onStale,
|
|
600
|
-
wait: mutationOptions?.wait,
|
|
601
|
-
operations: [
|
|
602
|
-
{
|
|
603
|
-
action: 'delete',
|
|
604
|
-
model: name,
|
|
605
|
-
id,
|
|
606
|
-
},
|
|
607
|
-
],
|
|
608
|
-
});
|
|
640
|
+
return mutateModel('delete', name, id, undefined, mutationOptions);
|
|
609
641
|
},
|
|
610
642
|
};
|
|
611
643
|
}
|
package/dist/client/auth.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface AuthResolveInput {
|
|
|
29
29
|
readonly apiKey?: string | ApiKeySetter | null;
|
|
30
30
|
readonly authToken?: string | null;
|
|
31
31
|
readonly baseURL?: string | null;
|
|
32
|
+
readonly databaseUrl?: string | null;
|
|
32
33
|
readonly dangerouslyAllowBrowser?: boolean;
|
|
33
34
|
};
|
|
34
35
|
readonly env: Record<string, string | undefined>;
|
|
@@ -41,16 +42,25 @@ export interface AuthResolveInput {
|
|
|
41
42
|
export declare function readProcessEnv(): Record<string, string | undefined>;
|
|
42
43
|
export declare function resolveApiKey(input: AuthResolveInput): string | ApiKeySetter | null;
|
|
43
44
|
export declare function resolveAuthToken(input: AuthResolveInput): string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the customer's own-Postgres connection string for write-back
|
|
47
|
+
* (dedicated/BYO tenant). Falls back to `DATABASE_URL` — the Prisma-style
|
|
48
|
+
* convention — so a server-side app that already exports it needs no extra
|
|
49
|
+
* config. Returns null for Ablo-managed storage (the hosted default).
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveDatabaseUrl(input: AuthResolveInput): string | null;
|
|
44
52
|
export declare const ABLO_DEFAULT_BASE_URL = "wss://mesh.ablo.finance";
|
|
45
53
|
export declare function resolveBaseURL(input: AuthResolveInput): string;
|
|
46
54
|
/**
|
|
47
55
|
* Browser guard — apiKey is server-side-only by default. Same check
|
|
48
56
|
* Anthropic, OpenAI, and Stripe ship: shipping `sk_live_...` to a
|
|
49
57
|
* browser exposes it in every visitor's network tab. Consumers opt
|
|
50
|
-
* in explicitly when
|
|
58
|
+
* in explicitly when the browser holds a minted session token
|
|
59
|
+
* (`ek_`/`rk_`) or routes through a server proxy.
|
|
51
60
|
*/
|
|
52
61
|
export declare function assertBrowserSafety(input: {
|
|
53
62
|
apiKey: string | ApiKeySetter | null;
|
|
63
|
+
databaseUrl?: string | null;
|
|
54
64
|
dangerouslyAllowBrowser: boolean | undefined;
|
|
55
65
|
}): void;
|
|
56
66
|
/**
|