@abloatai/ablo 0.11.1 → 0.11.2
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 +34 -0
- package/README.md +10 -2
- package/dist/Model.d.ts +39 -0
- package/dist/Model.js +68 -0
- package/dist/auth/credentialPolicy.d.ts +145 -0
- package/dist/auth/credentialPolicy.js +130 -0
- package/dist/cli.cjs +39 -6
- package/dist/client/Ablo.d.ts +39 -88
- package/dist/client/Ablo.js +38 -98
- package/dist/client/ApiClient.d.ts +10 -1
- package/dist/client/ApiClient.js +19 -11
- package/dist/client/auth.d.ts +12 -5
- package/dist/client/auth.js +2 -1
- package/dist/client/createModelProxy.d.ts +49 -10
- package/dist/client/createModelProxy.js +6 -0
- package/dist/client/httpClient.d.ts +17 -3
- package/dist/client/httpClient.js +1 -0
- package/dist/client/identity.js +134 -122
- package/dist/client/index.d.ts +1 -1
- package/dist/client/sessionMint.d.ts +15 -0
- package/dist/client/sessionMint.js +86 -0
- package/dist/errorCodes.d.ts +2 -0
- package/dist/errorCodes.js +2 -0
- package/dist/errors.d.ts +3 -2
- package/dist/errors.js +3 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -7
- package/dist/mutators/RecordingTransaction.js +14 -42
- package/dist/react/AbloProvider.d.ts +1 -6
- package/dist/react/AbloProvider.js +1 -5
- package/dist/react/context.d.ts +1 -31
- package/dist/react/context.js +2 -2
- package/dist/react/index.d.ts +0 -6
- package/dist/react/index.js +0 -7
- package/dist/react/useSyncStatus.d.ts +1 -1
- package/dist/realtime/index.d.ts +1 -1
- package/dist/schema/generate.js +1 -2
- package/dist/schema/schema.d.ts +13 -2
- package/dist/schema/schema.js +26 -0
- package/dist/surface.d.ts +29 -0
- package/dist/surface.js +60 -0
- package/dist/sync/ConnectionManager.d.ts +16 -5
- package/dist/sync/ConnectionManager.js +42 -7
- package/dist/transactions/TransactionQueue.d.ts +0 -11
- package/dist/transactions/TransactionQueue.js +12 -56
- package/dist/types/global.d.ts +3 -0
- package/dist/types/streams.d.ts +0 -22
- package/dist/utils/mobx-setup.js +1 -0
- package/docs/api-keys.md +49 -0
- package/docs/api.md +3 -2
- package/docs/client-behavior.md +1 -0
- package/docs/coordination.md +75 -21
- package/docs/examples/existing-python-backend.md +9 -5
- package/docs/examples/scoped-agent.md +1 -1
- package/docs/guarantees.md +4 -3
- package/docs/identity.md +89 -82
- package/docs/integration-guide.md +19 -10
- package/docs/migration.md +9 -2
- package/docs/quickstart.md +6 -2
- package/docs/react.md +3 -3
- package/docs/schema-contract.md +23 -5
- package/llms-full.txt +18 -16
- package/llms.txt +6 -6
- package/package.json +1 -1
- package/dist/api/index.d.ts +0 -10
- package/dist/api/index.js +0 -9
- package/dist/principal.d.ts +0 -44
- package/dist/principal.js +0 -49
- package/dist/react/SyncGroupProvider.d.ts +0 -19
- package/dist/react/SyncGroupProvider.js +0 -44
- package/dist/react/useClaim.d.ts +0 -29
- package/dist/react/useClaim.js +0 -42
- package/dist/react/usePresence.d.ts +0 -32
- package/dist/react/usePresence.js +0 -41
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -28,7 +28,6 @@ import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
|
|
|
28
28
|
import type { SyncGroupInput } from '../schema/roles.js';
|
|
29
29
|
import { type SyncStatus } from '../BaseSyncedStore.js';
|
|
30
30
|
import type { ClaimStream, ClaimWaitOptions, PresenceStream, Snapshot } from '../types/streams.js';
|
|
31
|
-
import type { ParticipantManager } from '../sync/participants.js';
|
|
32
31
|
import type { ClaimHandle, Duration, Claim } from '../types/streams.js';
|
|
33
32
|
import { type AbloApi, type AbloApiClientOptions, type AbloApiClaims } from './ApiClient.js';
|
|
34
33
|
import { type AbloHttpClient, type AbloHttpClientOptions } from './httpClient.js';
|
|
@@ -77,35 +76,24 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
77
76
|
* usually pass nothing). A long-lived key needs no refresh; the client uses
|
|
78
77
|
* it as-is.
|
|
79
78
|
*
|
|
80
|
-
* Accepts a static string
|
|
81
|
-
*
|
|
79
|
+
* Accepts a static string OR an async `() => Promise<string | null>` resolver
|
|
80
|
+
* — the single credential path. Use the resolver form for two cases:
|
|
82
81
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
*
|
|
91
|
-
* fresh bearer (`ek_`/`rk_`) your backend minted for the signed-in user. The
|
|
92
|
-
* client calls it once before connect and then keeps the key fresh for you —
|
|
93
|
-
* a refresh timer ahead of expiry plus re-mint on OS-wake / network-online /
|
|
94
|
-
* tab-focus, and a reactive re-mint when a probe finds the key stale. You
|
|
95
|
-
* never call a refresh method (Supabase `autoRefreshToken` model).
|
|
82
|
+
* - **Key rotation** (server): pull a fresh `sk_`/`pk_` from a vault on each
|
|
83
|
+
* bootstrap (AWS STS, GCP IAM, Vault).
|
|
84
|
+
* - **Short-lived per-user browser** auth: return the fresh `ek_`/`rk_` bearer
|
|
85
|
+
* your backend minted for the signed-in user. The client mints once before
|
|
86
|
+
* connect, then keeps it fresh for you — a refresh timer ahead of expiry
|
|
87
|
+
* plus re-mint on OS-wake / network-online / tab-focus, and a reactive
|
|
88
|
+
* re-mint when a probe finds the key stale. You never call a refresh method
|
|
89
|
+
* (Supabase `autoRefreshToken` model).
|
|
96
90
|
*
|
|
97
|
-
*
|
|
98
|
-
* (terminal →
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
getToken?: (() => Promise<string | null>) | undefined;
|
|
102
|
-
/**
|
|
103
|
-
* Convenience over {@link getToken}: a URL on YOUR backend that returns
|
|
104
|
-
* `{ token }`. The client POSTs to it (with cookies, so it's authed by the
|
|
105
|
-
* user's session) to mint + refresh the bearer. Ignored when `getToken` is
|
|
106
|
-
* set. Pure sugar — `getToken: () => fetch(url).then(r => r.json()).then(b => b.token)`.
|
|
91
|
+
* Resolver contract: resolve a token; resolve `null` when the login itself is
|
|
92
|
+
* gone (terminal → the client signs out / fails `ready()` with `session_expired`);
|
|
93
|
+
* or THROW on a transient failure (→ back off and retry, never sign out). A
|
|
94
|
+
* static string never refreshes — it is used as-is.
|
|
107
95
|
*/
|
|
108
|
-
|
|
96
|
+
apiKey?: string | ApiKeySetter | null | undefined;
|
|
109
97
|
/**
|
|
110
98
|
* Direct-URL convenience connector: a connection string to your own Postgres
|
|
111
99
|
* that Ablo can register for a dedicated tenant.
|
|
@@ -138,6 +126,9 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
138
126
|
* `AbloHttpClient<S>`, so stateful-only capabilities (`get`/`getAll`,
|
|
139
127
|
* `onChange`) are compile errors rather than latent runtime gaps.
|
|
140
128
|
*
|
|
129
|
+
* Note: session/credential minting (`sessions.create`) currently runs on the
|
|
130
|
+
* stateful (default) client, not the http client.
|
|
131
|
+
*
|
|
141
132
|
* @default 'websocket'
|
|
142
133
|
*/
|
|
143
134
|
transport?: 'websocket' | 'http' | undefined;
|
|
@@ -225,18 +216,6 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
225
216
|
* only for the advanced Model / Claim / Commit client.
|
|
226
217
|
*/
|
|
227
218
|
schema: Schema<S>;
|
|
228
|
-
/**
|
|
229
|
-
* Short-lived-bearer resolver for the per-user browser path (mirrors the
|
|
230
|
-
* public {@link AbloOptions.getToken}). The client mints the first token
|
|
231
|
-
* before connect and refreshes it (timer + wake/online/focus) — see
|
|
232
|
-
* {@link resolveCredentialResolver}.
|
|
233
|
-
*/
|
|
234
|
-
getToken?: (() => Promise<string | null>) | undefined;
|
|
235
|
-
/**
|
|
236
|
-
* Backend URL returning `{ token }`; sugar over {@link getToken}. Mirrors the
|
|
237
|
-
* public {@link AbloOptions.authEndpoint}.
|
|
238
|
-
*/
|
|
239
|
-
authEndpoint?: string | undefined;
|
|
240
219
|
/**
|
|
241
220
|
* @deprecated Server derives participant kind from the apiKey's
|
|
242
221
|
* scope. Pass apiKey only; this option will be removed once the
|
|
@@ -385,8 +364,8 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
385
364
|
* `create({ data })` / `update({ id, data })` / `delete({ id })` — writes
|
|
386
365
|
* `claim({ id })` — durable claim handle for coordinated writes
|
|
387
366
|
*/
|
|
388
|
-
export type {
|
|
389
|
-
import type { ModelOperations, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams,
|
|
367
|
+
export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimHandle, ModelOperations, } from './createModelProxy.js';
|
|
368
|
+
import type { ModelOperations, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ServerReadOptions } from './createModelProxy.js';
|
|
390
369
|
export type ModelOperationAction = 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
|
|
391
370
|
export type CommitWait = 'queued' | 'confirmed';
|
|
392
371
|
export interface ModelRead<T = Record<string, unknown>> {
|
|
@@ -394,23 +373,15 @@ export interface ModelRead<T = Record<string, unknown>> {
|
|
|
394
373
|
readonly stamp: number;
|
|
395
374
|
readonly claims: readonly ModelClaim[];
|
|
396
375
|
}
|
|
397
|
-
export type IfClaimedPolicy = 'return' | '
|
|
376
|
+
export type IfClaimedPolicy = 'return' | 'fail';
|
|
398
377
|
export interface ClaimedOptions {
|
|
399
378
|
/**
|
|
400
|
-
* What to do when another participant has claimed the target
|
|
401
|
-
* includes active claim metadata in the response
|
|
402
|
-
* claim
|
|
379
|
+
* What to do when another participant has claimed the target: `return`
|
|
380
|
+
* includes active claim metadata in the response; `fail` throws
|
|
381
|
+
* `AbloClaimedError`. Waiting for a claim to clear is a claim-side concern —
|
|
382
|
+
* take `ablo.<model>.claim({ id })` (it queues fairly); reads never block.
|
|
403
383
|
*/
|
|
404
384
|
readonly ifClaimed?: IfClaimedPolicy;
|
|
405
|
-
/** Max time to wait for peer claims to clear, in milliseconds. */
|
|
406
|
-
readonly claimedTimeout?: number;
|
|
407
|
-
/** HTTP API polling interval while waiting. WebSocket clients ignore it. */
|
|
408
|
-
readonly claimedPollInterval?: number;
|
|
409
|
-
/**
|
|
410
|
-
* Backpressure for `ifClaimed: 'wait'`: reject instead of waiting if the
|
|
411
|
-
* row's FIFO line is already `>= maxQueueDepth` deep.
|
|
412
|
-
*/
|
|
413
|
-
readonly maxQueueDepth?: number;
|
|
414
385
|
}
|
|
415
386
|
export type { ClaimWaitOptions } from '../types/streams.js';
|
|
416
387
|
export interface ModelReadOptions extends ClaimedOptions {
|
|
@@ -527,7 +498,7 @@ export interface ModelClient<T = Record<string, unknown>> {
|
|
|
527
498
|
* `limit`. Present on the stateless protocol client; the store-backed
|
|
528
499
|
* `.model(name)` accessor omits it (use the typed `ablo.<model>.list` there).
|
|
529
500
|
*/
|
|
530
|
-
list?(options?:
|
|
501
|
+
list?(options?: ServerReadOptions<T>): Promise<T[]>;
|
|
531
502
|
create(params: ModelMutationOptions & {
|
|
532
503
|
readonly data: Record<string, unknown>;
|
|
533
504
|
readonly id?: string | null;
|
|
@@ -560,7 +531,7 @@ export interface CreateUserSessionParams {
|
|
|
560
531
|
id: string;
|
|
561
532
|
};
|
|
562
533
|
/** Sync groups this session may subscribe to — typed (`'default'` or
|
|
563
|
-
* `<namespace>:<id>`; build with `syncGroup
|
|
534
|
+
* `<namespace>:<id>`; build with `syncGroup(kind, id)` from
|
|
564
535
|
* `@abloatai/ablo/schema`). Omit for the server default:
|
|
565
536
|
* `[org:<your org>, user:<user.id>]`. */
|
|
566
537
|
syncGroups?: readonly SyncGroupInput[];
|
|
@@ -585,7 +556,7 @@ export interface CreateAgentSessionParams<S extends SchemaRecord> {
|
|
|
585
556
|
[M in keyof S & string]?: readonly SessionOperation[];
|
|
586
557
|
};
|
|
587
558
|
/** Sync groups this session may subscribe to — typed (`'default'` or
|
|
588
|
-
* `<namespace>:<id>`; build with `syncGroup
|
|
559
|
+
* `<namespace>:<id>`; build with `syncGroup(kind, id)` from
|
|
589
560
|
* `@abloatai/ablo/schema`). Omit for the server default: the org
|
|
590
561
|
* anchor (`org:<your org>`) + the agent's own anchor. */
|
|
591
562
|
syncGroups?: readonly SyncGroupInput[];
|
|
@@ -667,7 +638,7 @@ export type Ablo<S extends SchemaRecord> = {
|
|
|
667
638
|
* Replace the bearer auth token used for the WebSocket upgrade and HTTP
|
|
668
639
|
* requests, WITHOUT tearing down the engine. Use to push a refreshed
|
|
669
640
|
* short-lived access key (the Stripe-style `ek_`/`rk_`) before it expires —
|
|
670
|
-
*
|
|
641
|
+
* the client's `apiKey`-resolver refresh loop calls this. Reuses the same
|
|
671
642
|
* rotation path as the internal capability-token refresh; safe to call before
|
|
672
643
|
* `ready()`. Also nudges a parked connection to re-probe with the new token.
|
|
673
644
|
*/
|
|
@@ -675,8 +646,8 @@ export type Ablo<S extends SchemaRecord> = {
|
|
|
675
646
|
/**
|
|
676
647
|
* Resolve the active bearer credential this engine authenticates with — the
|
|
677
648
|
* live `ek_`/`rk_` the WebSocket and HTTP transports currently carry (kept
|
|
678
|
-
* fresh by the `
|
|
679
|
-
* key. Returns `null` when no credential is set yet. Use it to authenticate
|
|
649
|
+
* fresh by the `apiKey`-resolver refresh loop), falling back to a configured
|
|
650
|
+
* API key. Returns `null` when no credential is set yet. Use it to authenticate
|
|
680
651
|
* a side-band request to the same server with the very token this client
|
|
681
652
|
* already holds — no extra mint round-trip.
|
|
682
653
|
*/
|
|
@@ -685,10 +656,10 @@ export type Ablo<S extends SchemaRecord> = {
|
|
|
685
656
|
* Register a re-mint hook for the short-lived access key. The connection
|
|
686
657
|
* layer calls it WHEN it finds the key stale (a `credential_stale` probe) or
|
|
687
658
|
* on an external nudge; the hook mints a fresh `ek_`/`rk_` from the still-valid
|
|
688
|
-
* login. Mirrors the `
|
|
689
|
-
* the login itself is gone (→ sign out), or THROW on a transient
|
|
690
|
-
* back off, never sign out).
|
|
691
|
-
* `
|
|
659
|
+
* login. Mirrors the `apiKey`-resolver contract: resolve a token, resolve
|
|
660
|
+
* `null` when the login itself is gone (→ sign out), or THROW on a transient
|
|
661
|
+
* failure (→ back off, never sign out). The client wires this automatically
|
|
662
|
+
* from a function `apiKey`. Safe to call before `ready()`.
|
|
692
663
|
*/
|
|
693
664
|
setCredentialRefresher(refresher: (() => Promise<string | null>) | null): void;
|
|
694
665
|
/**
|
|
@@ -703,14 +674,15 @@ export type Ablo<S extends SchemaRecord> = {
|
|
|
703
674
|
* Mint a short-lived, scoped **session token** for one end user — the
|
|
704
675
|
* Stripe `ephemeralKeys.create` / Supabase session shape. Call this on YOUR
|
|
705
676
|
* BACKEND (where the `sk_` secret key lives), then hand the returned
|
|
706
|
-
* `token` to that user's browser (typically via
|
|
707
|
-
* fetches). The browser presents it as the bearer; the sync-server verifies
|
|
677
|
+
* `token` to that user's browser (typically via a token route the browser's
|
|
678
|
+
* `apiKey` resolver fetches). The browser presents it as the bearer; the sync-server verifies
|
|
708
679
|
* it via `apiKeyProvider`.
|
|
709
680
|
*
|
|
710
681
|
* The browser must NEVER see the `sk_` key — only the per-user session token.
|
|
711
682
|
*
|
|
712
683
|
* Pass `{ user: { id } }` for a full-authority end-user session (mints `ek_`,
|
|
713
|
-
* `
|
|
684
|
+
* `participantKind: 'user'` attribution, stored as `actor_kind` on the delta
|
|
685
|
+
* row), or `{ agent: { id }, can: { tasks:
|
|
714
686
|
* ['update'] } }` for a scoped agent session (mints `rk_`); `can` is typed
|
|
715
687
|
* against your schema's model names. Always authenticates with the original
|
|
716
688
|
* `sk_` — never the client's exchanged sync credential.
|
|
@@ -822,24 +794,6 @@ export type Ablo<S extends SchemaRecord> = {
|
|
|
822
794
|
* are schema-powered sugar over the same model write/read path.
|
|
823
795
|
*/
|
|
824
796
|
model<T = Record<string, unknown>>(name: string): ModelClient<T>;
|
|
825
|
-
/**
|
|
826
|
-
* Canonical multiplayer participant surface. Joins a structured app
|
|
827
|
-
* target, derives the transport scope internally, opens a scoped
|
|
828
|
-
* claim on the existing WebSocket, and returns target-bound presence
|
|
829
|
-
* + claim helpers.
|
|
830
|
-
*
|
|
831
|
-
* ```ts
|
|
832
|
-
* const participant = await ablo.participants.join({
|
|
833
|
-
* type: 'File',
|
|
834
|
-
* id: 'src/foo.ts',
|
|
835
|
-
* path: 'src/foo.ts',
|
|
836
|
-
* range: { startLine: 10, endLine: 40 },
|
|
837
|
-
* });
|
|
838
|
-
* participant.presence.editing();
|
|
839
|
-
* const claim = participant.claims.claim('rewrite imports');
|
|
840
|
-
* ```
|
|
841
|
-
*/
|
|
842
|
-
readonly participants: ParticipantManager;
|
|
843
797
|
/**
|
|
844
798
|
* Capture a context-staleness watermark over a set of entities.
|
|
845
799
|
* Returns a flat snapshot with `stamp` (thread into writes as
|
|
@@ -991,9 +945,6 @@ export declare namespace Ablo {
|
|
|
991
945
|
type ClaimLost = _Streams.ClaimLost;
|
|
992
946
|
type Snapshot<TSchema extends _SchemaTypes.Schema = _SchemaTypes.Schema, K extends keyof TSchema['models'] = keyof TSchema['models']> = _Streams.Snapshot<TSchema, K>;
|
|
993
947
|
namespace Auth {
|
|
994
|
-
type Principal = _Streams.Principal;
|
|
995
|
-
type Session = _Streams.SessionRef;
|
|
996
|
-
type Agent = _Streams.AgentRef;
|
|
997
948
|
type Actor = _Streams.ParticipantRef;
|
|
998
949
|
}
|
|
999
950
|
namespace Participant {
|
package/dist/client/Ablo.js
CHANGED
|
@@ -27,7 +27,7 @@ import { initSyncEngine } from '../context.js';
|
|
|
27
27
|
import { noopObservability, browserOnlineStatus, defaultSessionErrorDetector, noopAnalytics, } from '../SyncEngineContext.js';
|
|
28
28
|
import { alwaysOnline } from '../adapters/alwaysOnline.js';
|
|
29
29
|
import { validateAbloOptions } from './validateAbloOptions.js';
|
|
30
|
-
import {
|
|
30
|
+
import { mintSession } from './sessionMint.js';
|
|
31
31
|
import { createAuthCredentialSource } from '../auth/credentialSource.js';
|
|
32
32
|
import { createInternalComponents } from './createInternalComponents.js';
|
|
33
33
|
import { resolveParticipantIdentity } from './identity.js';
|
|
@@ -670,32 +670,20 @@ function createDefaultMutationDispatcher(executor) {
|
|
|
670
670
|
// ── Auth normalization ─────────────────────────────────────────────────────
|
|
671
671
|
/**
|
|
672
672
|
* The one resolver the credential lifecycle needs: an async `() => token | null`,
|
|
673
|
-
* or `null` when auth is static (a plain long-lived `apiKey` with no
|
|
674
|
-
* the common case).
|
|
675
|
-
*
|
|
673
|
+
* or `null` when auth is static (a plain long-lived `apiKey` STRING with no
|
|
674
|
+
* refresh — the common case).
|
|
675
|
+
*
|
|
676
|
+
* The short-lived per-user browser path passes a FUNCTION `apiKey` (an
|
|
677
|
+
* `ApiKeySetter`): the SDK then drives the full credential lifecycle off it —
|
|
678
|
+
* mint-before-connect, the proactive refresh timer + wake/online/focus re-mint,
|
|
679
|
+
* and the reactive `credential_stale` re-mint. The resolver's contract is the
|
|
680
|
+
* `ApiKeySetter` contract end-to-end: resolve a token, resolve `null` when the
|
|
681
|
+
* login is gone (terminal → `session_expired` → sign out), or THROW on a
|
|
682
|
+
* transient failure (→ back off, never sign out).
|
|
676
683
|
*/
|
|
677
|
-
function resolveCredentialResolver(
|
|
678
|
-
if (
|
|
679
|
-
return
|
|
680
|
-
if (options.authEndpoint) {
|
|
681
|
-
const endpoint = options.authEndpoint;
|
|
682
|
-
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
683
|
-
return async () => {
|
|
684
|
-
// The endpoint lives on the consumer's OWN backend and is authed by the
|
|
685
|
-
// user's session cookie (hence `credentials: 'include'`); it returns the
|
|
686
|
-
// `ek_` to carry to the sync-server. A non-OK response is terminal
|
|
687
|
-
// (`null` → sign out), matching the `getToken` contract.
|
|
688
|
-
const res = await fetchImpl(endpoint, {
|
|
689
|
-
method: 'POST',
|
|
690
|
-
credentials: 'include',
|
|
691
|
-
headers: { 'Content-Type': 'application/json' },
|
|
692
|
-
});
|
|
693
|
-
if (!res.ok)
|
|
694
|
-
return null;
|
|
695
|
-
const body = (await res.json());
|
|
696
|
-
return body.token ?? null;
|
|
697
|
-
};
|
|
698
|
-
}
|
|
684
|
+
function resolveCredentialResolver(apiKey) {
|
|
685
|
+
if (typeof apiKey === 'function')
|
|
686
|
+
return apiKey;
|
|
699
687
|
return null;
|
|
700
688
|
}
|
|
701
689
|
export function Ablo(options) {
|
|
@@ -716,7 +704,7 @@ export function Ablo(options) {
|
|
|
716
704
|
// drives both the reactive re-mint (FSM `credential_stale`) and the proactive
|
|
717
705
|
// refresh timer + wake/online/focus triggers. Null for the common static
|
|
718
706
|
// `apiKey` path — no refresh needed.
|
|
719
|
-
const credentialResolver = resolveCredentialResolver(
|
|
707
|
+
const credentialResolver = resolveCredentialResolver(configuredApiKey);
|
|
720
708
|
const authCredentials = createAuthCredentialSource(internalOptions.capabilityToken ?? configuredAuthToken);
|
|
721
709
|
const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
|
|
722
710
|
assertBrowserSafety({
|
|
@@ -900,7 +888,7 @@ export function Ablo(options) {
|
|
|
900
888
|
// WebSocket upgrade + bootstrap carry a valid bearer (no tokenless first
|
|
901
889
|
// connect that has to self-heal). Only when a refreshing resolver is
|
|
902
890
|
// wired AND no static credential is already present. Contract mirrors
|
|
903
|
-
// `
|
|
891
|
+
// the `apiKey` resolver: `null` ⇒ the login is gone (terminal — fail ready so the
|
|
904
892
|
// app shows sign-in); a THROW ⇒ transient (rethrown; autoStart swallows
|
|
905
893
|
// and the lifecycle's online/wake triggers retry).
|
|
906
894
|
if (credentialResolver && !authCredentials.getAuthToken()) {
|
|
@@ -933,8 +921,9 @@ export function Ablo(options) {
|
|
|
933
921
|
kind,
|
|
934
922
|
configuredApiKey,
|
|
935
923
|
// Resolve identity against the LIVE token, not the construction-time
|
|
936
|
-
// `configuredAuthToken`. Consumers using `
|
|
937
|
-
// pass `authToken` at construction —
|
|
924
|
+
// `configuredAuthToken`. Consumers using a function `apiKey` (apps/web)
|
|
925
|
+
// never pass `authToken` at construction — the lifecycle mints the
|
|
926
|
+
// first `ek_`/`rk_` and calls `setAuthToken()` before
|
|
938
927
|
// `ready()`, which updates the shared credential source. Reading the frozen
|
|
939
928
|
// `configuredAuthToken` here made `/auth/identity` fire with no Bearer
|
|
940
929
|
// (→ `no_matching_provider` / `session_expired`) even though the JWT
|
|
@@ -1247,14 +1236,8 @@ export function Ablo(options) {
|
|
|
1247
1236
|
const current = listModelClaims(target);
|
|
1248
1237
|
if (current.length === 0)
|
|
1249
1238
|
return;
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
const queue = listModelClaimQueue(target);
|
|
1253
|
-
if (options?.maxQueueDepth !== undefined &&
|
|
1254
|
-
queue.length >= options.maxQueueDepth) {
|
|
1255
|
-
throw claimedError(target, current, 'queue_too_deep');
|
|
1256
|
-
}
|
|
1257
|
-
await waitForModelUnclaimed(target, { timeout: options?.claimedTimeout });
|
|
1239
|
+
// policy === 'fail' — gate the read only when the caller opts in.
|
|
1240
|
+
throw claimedError(target, current, 'model_claimed');
|
|
1258
1241
|
}
|
|
1259
1242
|
function wrapClaimHandle(claim, waited = false) {
|
|
1260
1243
|
const release = async () => {
|
|
@@ -1385,6 +1368,14 @@ export function Ablo(options) {
|
|
|
1385
1368
|
// errors so read interest never makes a read reject or stall.
|
|
1386
1369
|
enterScope: (scope) => store.enterScope(scope),
|
|
1387
1370
|
pinScope: (scope) => store.pinScope(scope),
|
|
1371
|
+
// `ablo.<model>.watch(ids, { ttl })` → a scoped participant join on
|
|
1372
|
+
// this model's sync group(s). The model-scoped relocation of the old
|
|
1373
|
+
// `ablo.participants.join({ scope: { <model>: ids } })`. WebSocket
|
|
1374
|
+
// only — `join` throws AbloConnectionError if the socket isn't ready.
|
|
1375
|
+
createWatch: (modelKey, ids, options) => participantManager.join({
|
|
1376
|
+
scope: { [modelKey]: ids },
|
|
1377
|
+
...(options?.ttl !== undefined ? { ttlSeconds: options.ttl } : {}),
|
|
1378
|
+
}),
|
|
1388
1379
|
});
|
|
1389
1380
|
}
|
|
1390
1381
|
const commits = {
|
|
@@ -1544,6 +1535,9 @@ export function Ablo(options) {
|
|
|
1544
1535
|
* (a wide-scope `rk_` on the hosted path), which control-plane routes
|
|
1545
1536
|
* rightly refuse (e.g. the user-session mint is sk_-gated). Counterpart to
|
|
1546
1537
|
* `getAuthToken()`, which resolves the sync-plane token.
|
|
1538
|
+
*
|
|
1539
|
+
* The sk_-only rule is enforced server-side; the credential KIND taxonomy
|
|
1540
|
+
* (secret/restricted/ephemeral/publishable) lives in `auth/credentialPolicy`.
|
|
1547
1541
|
*/
|
|
1548
1542
|
async function controlPlaneApiKey() {
|
|
1549
1543
|
return resolveApiKeyValue(configuredApiKey);
|
|
@@ -1564,7 +1558,7 @@ export function Ablo(options) {
|
|
|
1564
1558
|
store.nudgeReconnect();
|
|
1565
1559
|
},
|
|
1566
1560
|
async getAuthToken() {
|
|
1567
|
-
// The live short-lived bearer (set via `setAuthToken
|
|
1561
|
+
// The live short-lived bearer (set via `setAuthToken` / `apiKey`-resolver refresh)
|
|
1568
1562
|
// is the canonical credential; fall back to a configured API key.
|
|
1569
1563
|
//
|
|
1570
1564
|
// This is the SYNC-PLANE token (bootstrap, WS, query HTTP). Control-plane
|
|
@@ -1608,66 +1602,15 @@ export function Ablo(options) {
|
|
|
1608
1602
|
url,
|
|
1609
1603
|
bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
|
|
1610
1604
|
});
|
|
1611
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1614
|
-
//
|
|
1615
|
-
|
|
1616
|
-
// (`invalid_participant_kind`, the 2026-06-11 Pulse
|
|
1617
|
-
// cascade: the SDK's own blessed pattern 403'd and
|
|
1618
|
-
// integrators fell back to minting humans as agents).
|
|
1619
|
-
// `{ agent }` → POST /auth/capability → scoped `rk_`.
|
|
1620
|
-
// `can: { tasks: ['update'] }` serializes to the wire
|
|
1621
|
-
// allowlist (`tasks.update`); the Hub matches it
|
|
1622
|
-
// against every registered alias of the model.
|
|
1623
|
-
if (params.user) {
|
|
1624
|
-
const res = await mintUserSessionKey({
|
|
1625
|
-
apiKey,
|
|
1626
|
-
baseUrl,
|
|
1627
|
-
userId: params.user.id,
|
|
1628
|
-
...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
|
|
1629
|
-
ttlSeconds: params.ttlSeconds ?? 900,
|
|
1630
|
-
...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
|
|
1631
|
-
});
|
|
1632
|
-
return {
|
|
1633
|
-
object: 'session',
|
|
1634
|
-
id: res.id,
|
|
1635
|
-
token: res.token,
|
|
1636
|
-
expiresAt: res.expiresAt,
|
|
1637
|
-
organizationId: res.organizationId,
|
|
1638
|
-
// The ephemeral mint stores scope on the key row; reshape its flat
|
|
1639
|
-
// response into the session resource's scope block.
|
|
1640
|
-
scope: {
|
|
1641
|
-
organizationId: res.organizationId,
|
|
1642
|
-
syncGroups: res.syncGroups,
|
|
1643
|
-
operations: [],
|
|
1644
|
-
participantKind: 'user',
|
|
1645
|
-
participantId: res.participantId,
|
|
1646
|
-
},
|
|
1647
|
-
userMeta: params.userMeta ?? { id: res.participantId },
|
|
1648
|
-
};
|
|
1649
|
-
}
|
|
1650
|
-
const operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
|
|
1651
|
-
const res = await exchangeApiKey({
|
|
1605
|
+
// The two mint doors (`{ user }` → /auth/ephemeral-keys → `ek_`,
|
|
1606
|
+
// `{ agent, can }` → /auth/capability → scoped `rk_`) live in the shared
|
|
1607
|
+
// `mintSession` so this stateful client and the stateless HTTP client can
|
|
1608
|
+
// never drift on how a token is minted.
|
|
1609
|
+
return mintSession(params, {
|
|
1652
1610
|
apiKey,
|
|
1653
1611
|
baseUrl,
|
|
1654
|
-
participantKind: 'agent',
|
|
1655
|
-
participantId: params.agent.id,
|
|
1656
|
-
...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
|
|
1657
|
-
operations,
|
|
1658
|
-
ttlSeconds: params.ttlSeconds ?? 900,
|
|
1659
|
-
...(params.userMeta ? { userMeta: params.userMeta } : {}),
|
|
1660
1612
|
...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
|
|
1661
1613
|
});
|
|
1662
|
-
return {
|
|
1663
|
-
object: 'session',
|
|
1664
|
-
id: res.capabilityId,
|
|
1665
|
-
token: res.token,
|
|
1666
|
-
expiresAt: res.expiresAt,
|
|
1667
|
-
organizationId: res.organizationId,
|
|
1668
|
-
scope: res.scope,
|
|
1669
|
-
userMeta: res.userMeta,
|
|
1670
|
-
};
|
|
1671
1614
|
},
|
|
1672
1615
|
},
|
|
1673
1616
|
async dispose() {
|
|
@@ -1736,9 +1679,6 @@ export function Ablo(options) {
|
|
|
1736
1679
|
claims: publicClaims,
|
|
1737
1680
|
commits,
|
|
1738
1681
|
model,
|
|
1739
|
-
/** Structured multiplayer participation — target-first, no
|
|
1740
|
-
* sync-group strings in the common path. */
|
|
1741
|
-
participants: participantManager,
|
|
1742
1682
|
/** Context-staleness snapshot — see `engine.snapshot(...)` JSDoc. */
|
|
1743
1683
|
snapshot(entities) {
|
|
1744
1684
|
return createSnapshot({
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* IndexedDB, no WebSocket. It maps the public Model / Claim / Commit
|
|
6
6
|
* nouns directly to HTTP routes on sync-server.
|
|
7
7
|
*/
|
|
8
|
-
import type { AbloOptions, CommitResource, ClaimCreateOptions, ClaimWaitOptions, ModelClient, ModelClaim, ModelTarget } from './Ablo.js';
|
|
8
|
+
import type { AbloOptions, CommitResource, ClaimCreateOptions, ClaimWaitOptions, ModelClient, ModelClaim, ModelTarget, CreateSessionParams, AbloSession } from './Ablo.js';
|
|
9
|
+
import type { SchemaRecord } from '../schema/schema.js';
|
|
9
10
|
import type { ClaimHandle } from './createModelProxy.js';
|
|
10
11
|
import type { Duration } from '../utils/duration.js';
|
|
11
12
|
export type AbloApiClientOptions = Omit<AbloOptions, 'schema'> & {
|
|
@@ -133,5 +134,13 @@ export interface AbloApi {
|
|
|
133
134
|
* server with the credential this client already holds — no re-mint.
|
|
134
135
|
*/
|
|
135
136
|
getAuthToken(): Promise<string | null>;
|
|
137
|
+
/**
|
|
138
|
+
* Mint a short-lived scoped session — the Stripe `ephemeralKeys.create` shape.
|
|
139
|
+
* Minting is a control-plane HTTP call (no socket), so it lives on this stateless
|
|
140
|
+
* client too, not only the realtime one. `{ user }` → `ek_`, `{ agent, can }` → `rk_`.
|
|
141
|
+
*/
|
|
142
|
+
readonly sessions: {
|
|
143
|
+
create(params: CreateSessionParams<SchemaRecord>): Promise<AbloSession>;
|
|
144
|
+
};
|
|
136
145
|
}
|
|
137
146
|
export declare function createProtocolClient(options: AbloApiClientOptions): AbloApi;
|
package/dist/client/ApiClient.js
CHANGED
|
@@ -9,6 +9,7 @@ import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloVal
|
|
|
9
9
|
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfDatabaseUrlEnvIgnored, } from './auth.js';
|
|
10
10
|
import { registerDataSource } from './registerDataSource.js';
|
|
11
11
|
import { toSeconds } from '../utils/duration.js';
|
|
12
|
+
import { mintSession } from './sessionMint.js';
|
|
12
13
|
import { assertWriteOptions } from './writeOptionsSchema.js';
|
|
13
14
|
const DEFAULT_AGENT_LEASE = '10m';
|
|
14
15
|
export function createProtocolClient(options) {
|
|
@@ -228,20 +229,11 @@ export function createProtocolClient(options) {
|
|
|
228
229
|
const policy = options?.ifClaimed ?? defaultPolicy;
|
|
229
230
|
if (policy === 'return')
|
|
230
231
|
return;
|
|
232
|
+
// policy === 'fail' — gate the read only when the caller opts in.
|
|
231
233
|
const state = await listClaimState(target);
|
|
232
234
|
if (state.active.length === 0)
|
|
233
235
|
return;
|
|
234
|
-
|
|
235
|
-
throw claimedError(target, state.active, 'model_claimed');
|
|
236
|
-
}
|
|
237
|
-
if (options?.maxQueueDepth !== undefined &&
|
|
238
|
-
state.queue.length >= options.maxQueueDepth) {
|
|
239
|
-
throw claimedError(target, state.active, 'queue_too_deep');
|
|
240
|
-
}
|
|
241
|
-
await waitForNoClaims(target, {
|
|
242
|
-
timeout: options?.claimedTimeout,
|
|
243
|
-
pollInterval: options?.claimedPollInterval,
|
|
244
|
-
});
|
|
236
|
+
throw claimedError(target, state.active, 'model_claimed');
|
|
245
237
|
}
|
|
246
238
|
const commits = {
|
|
247
239
|
async create(commitOptions) {
|
|
@@ -656,6 +648,22 @@ export function createProtocolClient(options) {
|
|
|
656
648
|
claims,
|
|
657
649
|
commits,
|
|
658
650
|
model,
|
|
651
|
+
sessions: {
|
|
652
|
+
async create(params) {
|
|
653
|
+
// Stateless mint: the configured key IS the control-plane credential here
|
|
654
|
+
// (no startup `rk_` exchange runs on this client). Reuse the resolved base
|
|
655
|
+
// URL + fetch; the shared `mintSession` owns the two server doors.
|
|
656
|
+
const apiKey = await resolveApiKeyValue(configuredApiKey);
|
|
657
|
+
if (!apiKey) {
|
|
658
|
+
throw new AbloAuthenticationError('sessions.create requires a secret (sk_) API key — call it from your backend, not the browser.', { code: 'apikey_missing' });
|
|
659
|
+
}
|
|
660
|
+
return mintSession(params, {
|
|
661
|
+
apiKey,
|
|
662
|
+
baseUrl: apiBaseUrl,
|
|
663
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
664
|
+
});
|
|
665
|
+
},
|
|
666
|
+
},
|
|
659
667
|
async getAuthToken() {
|
|
660
668
|
// Mirror `authHeaders()`: a configured API key wins, else the
|
|
661
669
|
// construction-time auth token. Resolve the (possibly async) key setter.
|
package/dist/client/auth.d.ts
CHANGED
|
@@ -12,13 +12,20 @@
|
|
|
12
12
|
* explicit options so generated apps do not accrete hidden env knobs.
|
|
13
13
|
*/
|
|
14
14
|
/**
|
|
15
|
-
* Async callable that resolves
|
|
15
|
+
* Async callable that resolves the current credential. Mirrors the shape
|
|
16
16
|
* Anthropic / OpenAI / Stripe ship — used for credential rotation
|
|
17
|
-
* (e.g. AWS STS, GCP IAM, Vault)
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* (e.g. AWS STS, GCP IAM, Vault) AND the short-lived per-user browser
|
|
18
|
+
* path (mint a fresh `ek_`/`rk_` from the signed-in session). Re-exported
|
|
19
|
+
* from `./Ablo` so existing import paths work; defined here so this module
|
|
20
|
+
* has no circular dependency back to `Ablo.ts`.
|
|
21
|
+
*
|
|
22
|
+
* Contract: resolve a token; resolve `null` when the login itself is gone
|
|
23
|
+
* (terminal → the credential lifecycle treats this as `session_expired` and
|
|
24
|
+
* signs out); or THROW on a transient failure (→ back off and retry, never
|
|
25
|
+
* sign out). A long-lived static `apiKey` string needs none of this — it is
|
|
26
|
+
* used as-is. This is the single credential resolver the SDK supports.
|
|
20
27
|
*/
|
|
21
|
-
export type ApiKeySetter = () => Promise<string>;
|
|
28
|
+
export type ApiKeySetter = () => Promise<string | null>;
|
|
22
29
|
export interface AuthResolveInput {
|
|
23
30
|
/**
|
|
24
31
|
* The full options bag the caller passed to `Ablo()`. Resolvers
|
package/dist/client/auth.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* explicit options so generated apps do not accrete hidden env knobs.
|
|
13
13
|
*/
|
|
14
14
|
import { AbloAuthenticationError } from '../errors.js';
|
|
15
|
+
import { classifyCredentialKind } from '../auth/credentialPolicy.js';
|
|
15
16
|
/**
|
|
16
17
|
* Read `process.env` defensively. Works in browser (where `process`
|
|
17
18
|
* is undefined), Node, and edge runtimes that expose a partial
|
|
@@ -137,7 +138,7 @@ export function assertBrowserSafety(input) {
|
|
|
137
138
|
if (!input.dangerouslyAllowBrowser &&
|
|
138
139
|
inBrowser &&
|
|
139
140
|
typeof input.apiKey === 'string' &&
|
|
140
|
-
input.apiKey
|
|
141
|
+
classifyCredentialKind(input.apiKey) === 'secret') {
|
|
141
142
|
throw new AbloAuthenticationError("It looks like you're running in a browser-like environment.\n\n" +
|
|
142
143
|
'This is disabled by default — your secret API key would be ' +
|
|
143
144
|
"exposed to every visitor's network tab. If you understand the risks " +
|