@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
|
@@ -19,6 +19,7 @@ import type { ModelRegistry } from '../ModelRegistry.js';
|
|
|
19
19
|
import type { ObjectPool } from '../ObjectPool.js';
|
|
20
20
|
import type { SyncClient } from '../SyncClient.js';
|
|
21
21
|
import type { HydrationCoordinator } from '../sync/HydrationCoordinator.js';
|
|
22
|
+
import type { JoinedParticipant } from '../sync/participants.js';
|
|
22
23
|
import type { LoadWhere } from '../query/types.js';
|
|
23
24
|
import { ModelScope } from '../types/index.js';
|
|
24
25
|
import type { Duration, Claim, ClaimHandle, ClaimWaitOptions, Snapshot, TargetRange } from '../types/streams.js';
|
|
@@ -28,7 +29,10 @@ export interface ModelClientMeta {
|
|
|
28
29
|
}
|
|
29
30
|
export declare function getModelClientMeta(modelClient: unknown): ModelClientMeta | undefined;
|
|
30
31
|
export type ModelListScope = ModelScope | 'live' | 'archived' | 'all';
|
|
31
|
-
|
|
32
|
+
/** Options for the sync LOCAL-pool reads `get`/`getAll`/`onChange` (JS
|
|
33
|
+
* `filter`, equality `where`, lifecycle `state`). The local-reactive axis;
|
|
34
|
+
* contrast {@link ServerReadOptions} (the async server axis). */
|
|
35
|
+
export interface LocalReadOptions<T> {
|
|
32
36
|
where?: Partial<T>;
|
|
33
37
|
/** Arbitrary local predicate. Applied after `where`. */
|
|
34
38
|
filter?: (entity: T) => boolean;
|
|
@@ -42,8 +46,11 @@ export interface ModelListOptions<T> {
|
|
|
42
46
|
* sync-group `scope`. */
|
|
43
47
|
state?: ModelListScope;
|
|
44
48
|
}
|
|
45
|
-
export type
|
|
46
|
-
|
|
49
|
+
export type LocalCountOptions<T> = Pick<LocalReadOptions<T>, 'where' | 'filter' | 'state'>;
|
|
50
|
+
/** Options for the async SERVER reads `retrieve`/`list` (operator `where` DSL,
|
|
51
|
+
* `type`, `expand`). The server-async axis; contrast {@link LocalReadOptions}
|
|
52
|
+
* (the local-reactive axis). */
|
|
53
|
+
export interface ServerReadOptions<T> {
|
|
47
54
|
/**
|
|
48
55
|
* Filter for the lookup. Accepts:
|
|
49
56
|
* - object form: `{ name: 'foo' }` (equality, array values → `IN`)
|
|
@@ -71,8 +78,8 @@ export interface ModelLoadOptions<T> {
|
|
|
71
78
|
expand?: readonly string[];
|
|
72
79
|
}
|
|
73
80
|
/** Options for the single-row async server read `retrieve({ id })`. A subset of
|
|
74
|
-
* {@link
|
|
75
|
-
export type
|
|
81
|
+
* {@link ServerReadOptions} — `where`/`limit`/`orderBy` are fixed by the id. */
|
|
82
|
+
export type ServerRetrieveOptions = Pick<ServerReadOptions<unknown>, 'type' | 'expand'>;
|
|
76
83
|
export interface ModelCollaboration<T> {
|
|
77
84
|
createClaim(options: {
|
|
78
85
|
target: {
|
|
@@ -161,6 +168,14 @@ export interface ModelCollaboration<T> {
|
|
|
161
168
|
* Forwards to `BaseSyncedStore.pinScope`.
|
|
162
169
|
*/
|
|
163
170
|
pinScope?(scope: Record<string, string>): void | Promise<void>;
|
|
171
|
+
/**
|
|
172
|
+
* Open a presence/claim subscription on this model's sync group(s) and
|
|
173
|
+
* return the live participant handle. Backs `ablo.<model>.watch(ids)` —
|
|
174
|
+
* the relocation of the old `ablo.participants.join({ scope })`. WebSocket
|
|
175
|
+
* only (presence needs a socket); absent on non-ws constructions, in which
|
|
176
|
+
* case the proxy throws a clear error. Forwards to `participantManager.join`.
|
|
177
|
+
*/
|
|
178
|
+
createWatch?(modelKey: string, ids: string | readonly string[], options?: WatchOptions): Promise<JoinedParticipant>;
|
|
164
179
|
}
|
|
165
180
|
export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
166
181
|
/** Phase shown to observers while held. Defaults to `'editing'`. */
|
|
@@ -276,7 +291,7 @@ export interface ClaimApi<T> {
|
|
|
276
291
|
/** Release a manual claim handle early. Single-write claims auto-release. */
|
|
277
292
|
release(params: ClaimLookupParams<T> | ClaimHandle<T>): Promise<void>;
|
|
278
293
|
}
|
|
279
|
-
export interface ModelRetrieveParams extends
|
|
294
|
+
export interface ModelRetrieveParams extends ServerRetrieveOptions {
|
|
280
295
|
readonly id: string;
|
|
281
296
|
}
|
|
282
297
|
export interface ModelCreateParams<T, CreateInput> extends MutationOptions {
|
|
@@ -293,6 +308,15 @@ export interface ModelDeleteParams<T> extends MutationOptions {
|
|
|
293
308
|
readonly id: string;
|
|
294
309
|
readonly claim?: ClaimHandle<T> | ClaimTargetOptions<T> | null;
|
|
295
310
|
}
|
|
311
|
+
/** Options for the WebSocket-only `ablo.<model>.watch(ids, options?)`. */
|
|
312
|
+
export interface WatchOptions {
|
|
313
|
+
/**
|
|
314
|
+
* Lease TTL for the underlying presence claim — the participant
|
|
315
|
+
* auto-releases after this if the holder dies. Compact duration string
|
|
316
|
+
* (`'5m'`) or ms number, mirroring the claim `ttl`.
|
|
317
|
+
*/
|
|
318
|
+
ttl?: Duration;
|
|
319
|
+
}
|
|
296
320
|
export interface ModelOperations<T, CreateInput> {
|
|
297
321
|
/**
|
|
298
322
|
* Read a single entity by id from the **server** — async. Resolves through
|
|
@@ -316,7 +340,7 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
316
340
|
* Mirrors `stripe.customers.list({...})` — network-backed. For a synchronous
|
|
317
341
|
* read of the local graph use `getAll(...)`.
|
|
318
342
|
*/
|
|
319
|
-
list(options?:
|
|
343
|
+
list(options?: ServerReadOptions<T>): Promise<T[]>;
|
|
320
344
|
/**
|
|
321
345
|
* Synchronous snapshot of a single entity from the **local graph** — no
|
|
322
346
|
* network. Returns `undefined` when the row isn't resident (cold hosted
|
|
@@ -329,9 +353,9 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
329
353
|
* no network round-trip. Empty until `retrieve`/`list`/bootstrap has warmed
|
|
330
354
|
* the graph.
|
|
331
355
|
*/
|
|
332
|
-
getAll(options?:
|
|
356
|
+
getAll(options?: LocalReadOptions<T>): T[];
|
|
333
357
|
/** Count entities in the **local graph** (synchronous, no network). */
|
|
334
|
-
getCount(options?:
|
|
358
|
+
getCount(options?: LocalCountOptions<T>): number;
|
|
335
359
|
/**
|
|
336
360
|
* Create a new entity — **optimistic, offline-first**. Resolves once
|
|
337
361
|
* the mutation is queued locally, not when the server confirms.
|
|
@@ -370,7 +394,22 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
370
394
|
* ```
|
|
371
395
|
*/
|
|
372
396
|
claim: ClaimApi<T>;
|
|
397
|
+
/**
|
|
398
|
+
* Subscribe this client to the sync group(s) for one or more rows of this
|
|
399
|
+
* model and get a live participant handle back — presence (`.peers`), the
|
|
400
|
+
* scoped claim stream (`.claims`), and `.leave()` / `await using` disposal.
|
|
401
|
+
*
|
|
402
|
+
* The model-scoped relocation of the former `ablo.participants.join({
|
|
403
|
+
* scope: { <model>: ids } })`. WebSocket only — presence needs a socket, so
|
|
404
|
+
* this is absent on HTTP clients and throws on any non-ws construction.
|
|
405
|
+
*
|
|
406
|
+
* ```ts
|
|
407
|
+
* await using participant = await ablo.slides.watch(slideIds, { ttl: '5m' });
|
|
408
|
+
* participant.peers; // who else is here
|
|
409
|
+
* ```
|
|
410
|
+
*/
|
|
411
|
+
watch(ids: string | readonly string[], options?: WatchOptions): Promise<JoinedParticipant>;
|
|
373
412
|
/** Listen for changes (callback called on every change). */
|
|
374
|
-
onChange(callback: (entities: T[]) => void, options?:
|
|
413
|
+
onChange(callback: (entities: T[]) => void, options?: LocalReadOptions<T>): () => void;
|
|
375
414
|
}
|
|
376
415
|
export declare function createModelProxy<T, C>(schemaKey: string, registeredModelName: string, objectPool: ObjectPool, syncClient: SyncClient, registry: ModelRegistry, hydration: HydrationCoordinator, collaboration?: ModelCollaboration<T>): ModelOperations<T, C>;
|
|
@@ -509,6 +509,12 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
509
509
|
// `claim` is a callable namespace (take a claim) carrying the coordination
|
|
510
510
|
// readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
|
|
511
511
|
claim: claimApi,
|
|
512
|
+
watch: guard((ids, options) => {
|
|
513
|
+
if (!collaboration?.createWatch) {
|
|
514
|
+
throw new AbloValidationError(`Model "${schemaKey}" was built without a WebSocket runtime, so watch() is unavailable here. Presence needs a live socket — use the standard Ablo({ schema, apiKey }) client (not the HTTP transport).`, { code: 'model_watch_not_configured' });
|
|
515
|
+
}
|
|
516
|
+
return collaboration.createWatch(schemaKey, ids, options);
|
|
517
|
+
}),
|
|
512
518
|
onChange(callback, options) {
|
|
513
519
|
return autorun(() => {
|
|
514
520
|
const entities = this.getAll(options);
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
* surface as the browser client — typed proxies, stateless transport.
|
|
24
24
|
*/
|
|
25
25
|
import { type AbloApiClientOptions } from './ApiClient.js';
|
|
26
|
-
import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions } from './Ablo.js';
|
|
27
|
-
import type { ModelCreateParams, ModelDeleteParams,
|
|
26
|
+
import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions, CreateSessionParams, AbloSession } from './Ablo.js';
|
|
27
|
+
import type { ModelCreateParams, ModelDeleteParams, ServerReadOptions, ModelRetrieveParams, ModelUpdateParams } from './createModelProxy.js';
|
|
28
28
|
import type { Schema, SchemaRecord, InferModel, InferCreate } from '../schema/schema.js';
|
|
29
29
|
export interface AbloHttpClientOptions<S extends SchemaRecord> extends Omit<AbloApiClientOptions, 'schema'> {
|
|
30
30
|
/** The schema — used for TYPING only (typed model proxies); never sent or used at runtime. */
|
|
@@ -36,10 +36,16 @@ export interface AbloHttpClientOptions<S extends SchemaRecord> extends Omit<Ablo
|
|
|
36
36
|
* and the durable-lease claim plane (`claim` — acquire/hold/release). It does NOT
|
|
37
37
|
* include `get`/`getAll`/`getCount` (local synced-pool reads) or `onChange` (live
|
|
38
38
|
* subscription); those need the stateful plane and are absent BY TYPE here.
|
|
39
|
+
*
|
|
40
|
+
* Read-shape asymmetry (by design, not a gap): `retrieve(...)` returns a
|
|
41
|
+
* `ModelRead<T>` envelope `{ data, stamp, claims }` — the stateless client has no
|
|
42
|
+
* local graph, so the watermark/claims the stateful client reads from its pool
|
|
43
|
+
* must ride inline on the read (an agent needs the `stamp` to do a stale-guarded
|
|
44
|
+
* write; there is no `snapshot()` to fetch it from). `list(...)` returns a bare `T[]`.
|
|
39
45
|
*/
|
|
40
46
|
export interface HttpModelClient<T, C = T> {
|
|
41
47
|
retrieve(params: ModelRetrieveParams & ModelReadOptions): Promise<ModelRead<T>>;
|
|
42
|
-
list(options?:
|
|
48
|
+
list(options?: ServerReadOptions<T>): Promise<T[]>;
|
|
43
49
|
create(params: ModelCreateParams<T, C>): Promise<CommitReceipt>;
|
|
44
50
|
update(params: ModelUpdateParams<C>): Promise<CommitReceipt>;
|
|
45
51
|
delete(params: ModelDeleteParams<T>): Promise<CommitReceipt>;
|
|
@@ -61,6 +67,14 @@ export type AbloHttpClient<S extends SchemaRecord> = {
|
|
|
61
67
|
dispose(): Promise<void>;
|
|
62
68
|
/** Resolve the bearer credential this client authenticates with (see `AbloApi.getAuthToken`). */
|
|
63
69
|
getAuthToken(): Promise<string | null>;
|
|
70
|
+
/**
|
|
71
|
+
* Mint a short-lived scoped session (Stripe ephemeral-key shape). Minting is a
|
|
72
|
+
* stateless control-plane call, so — unlike `get`/`getAll`/`onChange` — it IS
|
|
73
|
+
* available on the HTTP client. `{ user }` → `ek_`, `{ agent, can }` → `rk_`.
|
|
74
|
+
*/
|
|
75
|
+
readonly sessions: {
|
|
76
|
+
create(params: CreateSessionParams<S>): Promise<AbloSession>;
|
|
77
|
+
};
|
|
64
78
|
/** String-keyed model accessor (for dynamic model names). */
|
|
65
79
|
model<T = Record<string, unknown>>(name: string): HttpModelClient<T>;
|
|
66
80
|
};
|
package/dist/client/identity.js
CHANGED
|
@@ -21,137 +21,153 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { AbloAuthenticationError } from '../errors.js';
|
|
23
23
|
import { exchangeApiKey } from '../auth/index.js';
|
|
24
|
+
import { mintUserSessionKey } from '../auth/index.js';
|
|
24
25
|
import { resolveIdentity } from '../auth/index.js';
|
|
25
26
|
import { createRefreshScheduler, } from '../auth/index.js';
|
|
27
|
+
import { resolveCredential, } from '../auth/credentialPolicy.js';
|
|
26
28
|
import { resolveApiKeyValue, resolveBootstrapBaseUrl } from './auth.js';
|
|
27
29
|
export async function resolveParticipantIdentity(input) {
|
|
28
30
|
const { options, internalOptions, url, kind, configuredApiKey, configuredAuthToken, bootstrapHelper, auth, logger, } = input;
|
|
29
31
|
const apiKeyValue = await resolveApiKeyValue(configuredApiKey);
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
refreshScheduler: null,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
// Branch 1: hosted-cloud (apiKey only, no caller-supplied capability token)
|
|
64
|
-
if (apiKeyValue && !options.capabilityToken) {
|
|
65
|
-
return resolveHosted({
|
|
66
|
-
apiKeyValue,
|
|
67
|
-
configuredApiKey,
|
|
68
|
-
url,
|
|
69
|
-
kind,
|
|
70
|
-
options,
|
|
71
|
-
bootstrapHelper,
|
|
72
|
-
auth,
|
|
73
|
-
logger,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
// Branch 2: self-derived (capability token present, identity unknown)
|
|
77
|
-
if (!internalOptions.organizationId ||
|
|
78
|
-
(kind === 'agent' ? !options.agentId : !options.user?.id)) {
|
|
79
|
-
// Fail fast on the missing-credential case. We're here because there's no
|
|
80
|
-
// apiKey (Branch 1) and the identity isn't caller-supplied (Branch 3), so
|
|
81
|
-
// `initialCapToken` is the only thing that can authenticate the
|
|
82
|
-
// `/auth/identity` call. When it's absent — the common cause being
|
|
83
|
-
// `getToken()` resolving to `null` (no/expired session, see
|
|
84
|
-
// `getSyncCapabilityToken`) — the request can only come back as the server's
|
|
85
|
-
// opaque `identity_resolve_failed: no_matching_provider`. Surface the real
|
|
86
|
-
// condition locally instead: `session_expired` is the registered,
|
|
87
|
-
// re-authenticate-able code, and we never make a doomed round-trip.
|
|
88
|
-
if (!initialCapToken) {
|
|
89
|
-
throw new AbloAuthenticationError('No auth token available to resolve identity — the session token is ' +
|
|
90
|
-
'missing or expired. Ensure `getToken()` returns a valid token, or ' +
|
|
91
|
-
'pass `apiKey` / `capabilityToken`.', { code: 'session_expired' });
|
|
92
|
-
}
|
|
93
|
-
// Single source of truth for the http(s) base — coerces ws/wss → http/https
|
|
94
|
-
// even when `bootstrapBaseUrl` is an explicit override (see auth.ts).
|
|
95
|
-
const baseUrl = resolveBootstrapBaseUrl({
|
|
96
|
-
url,
|
|
97
|
-
bootstrapBaseUrl: options.bootstrapBaseUrl,
|
|
98
|
-
});
|
|
99
|
-
const identity = await resolveIdentity({
|
|
32
|
+
// Single source of truth for the http(s) base — coerces ws/wss → http/https
|
|
33
|
+
// even when `bootstrapBaseUrl` is an explicit override (see auth.ts).
|
|
34
|
+
const baseUrl = resolveBootstrapBaseUrl({
|
|
35
|
+
url,
|
|
36
|
+
bootstrapBaseUrl: options.bootstrapBaseUrl,
|
|
37
|
+
});
|
|
38
|
+
// `internalOptions.organizationId` + a caller-supplied participant id is the
|
|
39
|
+
// legacy explicit path: the caller already knows its own identity, so no
|
|
40
|
+
// server round-trip is needed.
|
|
41
|
+
const hasExplicitIdentity = internalOptions.organizationId != null &&
|
|
42
|
+
(kind === 'agent' ? options.agentId != null : options.user?.id != null);
|
|
43
|
+
// The connect-time credential ROUTING decision lives in `credentialPolicy`:
|
|
44
|
+
// classify the apiKey (sk_/ek_/rk_/pk_) and route. The hosted exchange is the
|
|
45
|
+
// one mint the policy performs (delegating to the injected `exchangeApiKey`);
|
|
46
|
+
// every other route just hands back the bearer to use. We then switch on the
|
|
47
|
+
// resolved `kind` below to wire up scope + the refresh scheduler.
|
|
48
|
+
const cred = await resolveCredential({
|
|
49
|
+
apiKeyValue,
|
|
50
|
+
configuredApiKey,
|
|
51
|
+
capabilityToken: options.capabilityToken,
|
|
52
|
+
authToken: configuredAuthToken,
|
|
53
|
+
hasExplicitIdentity,
|
|
54
|
+
}, {
|
|
55
|
+
primitives: {
|
|
56
|
+
exchangeApiKey,
|
|
57
|
+
mintUserSessionKey,
|
|
58
|
+
resolveIdentity,
|
|
59
|
+
resolveApiKeyValue,
|
|
60
|
+
},
|
|
61
|
+
exchangeArgs: {
|
|
100
62
|
baseUrl,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
63
|
+
participantKind: (kind === 'agent' ? 'agent' : 'system'),
|
|
64
|
+
participantId: options.agentId ?? options.user?.id,
|
|
65
|
+
wideScope: true,
|
|
66
|
+
ttlSeconds: 3600,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
switch (cred.kind) {
|
|
70
|
+
case 'publishable':
|
|
71
|
+
// `pk_` — a long-lived, browser-safe, READ-ONLY project key. Used DIRECTLY
|
|
72
|
+
// as the bearer and NEVER exchanged for a short-lived capability — so it
|
|
73
|
+
// never expires and there is nothing to refresh. The sync-server's
|
|
74
|
+
// `apiKeyProvider` resolves the org + read-only scope from the key itself;
|
|
75
|
+
// we still call `/auth/identity` (authenticated by the `pk_` bearer) to
|
|
76
|
+
// learn the account scope + syncGroups for the bootstrap cache.
|
|
77
|
+
return resolveViaIdentity({
|
|
78
|
+
bearer: cred.getBearer,
|
|
79
|
+
baseUrl,
|
|
80
|
+
options,
|
|
81
|
+
bootstrapHelper,
|
|
82
|
+
auth,
|
|
83
|
+
});
|
|
84
|
+
case 'exchange':
|
|
85
|
+
// Hosted-cloud (`sk_`): the policy exchanged the apiKey for a capability
|
|
86
|
+
// token; here we apply the returned scope and set up the refresh scheduler.
|
|
87
|
+
return resolveHosted({
|
|
88
|
+
cred,
|
|
89
|
+
configuredApiKey,
|
|
90
|
+
baseUrl,
|
|
91
|
+
kind,
|
|
92
|
+
options,
|
|
93
|
+
bootstrapHelper,
|
|
94
|
+
auth,
|
|
95
|
+
logger,
|
|
96
|
+
});
|
|
97
|
+
case 'pre-minted':
|
|
98
|
+
// Self-derived: a pre-minted `ek_`/`rk_` bearer or an explicit capability
|
|
99
|
+
// token authenticates `/auth/identity` directly (no exchange, no refresh).
|
|
100
|
+
return resolveViaIdentity({
|
|
101
|
+
bearer: cred.getBearer,
|
|
102
|
+
baseUrl,
|
|
103
|
+
options,
|
|
104
|
+
bootstrapHelper,
|
|
105
|
+
auth,
|
|
106
|
+
});
|
|
107
|
+
case 'explicit': {
|
|
108
|
+
// Legacy explicit (self-hosted, pre-Phase-3 — caller knows its own
|
|
109
|
+
// organizationId + user/agentId).
|
|
110
|
+
const userId = kind === 'agent' ? options.agentId : options.user.id;
|
|
111
|
+
const accountScope = internalOptions.organizationId;
|
|
112
|
+
bootstrapHelper.setCacheScope(accountScope);
|
|
113
|
+
bootstrapHelper.setSyncGroups(options.syncGroups);
|
|
114
|
+
auth.setAuthToken(cred.getBearer);
|
|
115
|
+
return {
|
|
116
|
+
userId,
|
|
117
|
+
accountScope,
|
|
118
|
+
teamIds: kind === 'user' ? options.user?.teamIds : undefined,
|
|
119
|
+
capabilityToken: cred.getBearer,
|
|
120
|
+
syncGroups: options.syncGroups,
|
|
121
|
+
participantKind: kind,
|
|
122
|
+
refreshScheduler: null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
130
125
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Shared `/auth/identity` resolution for the `pk_` (publishable) and pre-minted
|
|
129
|
+
* (`ek_`/`rk_` or explicit cap token) routes: the bearer is used as-is, the
|
|
130
|
+
* server resolves the identity, and caller-passed syncGroups are MERGED with the
|
|
131
|
+
* server-resolved set.
|
|
132
|
+
*/
|
|
133
|
+
async function resolveViaIdentity(input) {
|
|
134
|
+
const { bearer, baseUrl, options, bootstrapHelper, auth } = input;
|
|
135
|
+
const identity = await resolveIdentity({ baseUrl, authToken: bearer });
|
|
136
|
+
// Merge caller-passed syncGroups with server-resolved ones rather than letting
|
|
137
|
+
// the server's response silently overwrite. Browser consumers (apps/web's
|
|
138
|
+
// SyncEngineProvider) compose `['default', 'org:${orgId}', 'user:${userId}',
|
|
139
|
+
// ...team:]` from the resolved session and pass it via `<AbloProvider
|
|
140
|
+
// syncGroups>`; before this merge, the self-derived path dropped that set on
|
|
141
|
+
// the floor in favor of `/auth/identity`'s response, which is empty for
|
|
142
|
+
// cookie-auth users today (apps/sync-server/src/routes/auth.ts only populates
|
|
143
|
+
// from `effectiveSyncGroups`, the cap-narrowed list). Empty syncGroups →
|
|
144
|
+
// server bootstrap falls back to `['default']` → no deltas fan out → live
|
|
145
|
+
// updates appear only on hard reload.
|
|
146
|
+
const callerGroups = options.syncGroups ?? [];
|
|
147
|
+
const mergedSyncGroups = callerGroups.length > 0
|
|
148
|
+
? [...new Set([...callerGroups, ...identity.syncGroups])]
|
|
149
|
+
: identity.syncGroups;
|
|
150
|
+
bootstrapHelper.setCacheScope(identity.accountScope);
|
|
151
|
+
bootstrapHelper.setSyncGroups(mergedSyncGroups);
|
|
152
|
+
auth.setAuthToken(bearer);
|
|
138
153
|
return {
|
|
139
|
-
userId,
|
|
140
|
-
accountScope,
|
|
141
|
-
teamIds:
|
|
142
|
-
capabilityToken:
|
|
143
|
-
syncGroups:
|
|
144
|
-
participantKind:
|
|
154
|
+
userId: identity.participantId,
|
|
155
|
+
accountScope: identity.accountScope,
|
|
156
|
+
teamIds: undefined,
|
|
157
|
+
capabilityToken: bearer,
|
|
158
|
+
syncGroups: mergedSyncGroups,
|
|
159
|
+
participantKind: identity.participantKind,
|
|
145
160
|
refreshScheduler: null,
|
|
146
161
|
};
|
|
147
162
|
}
|
|
148
163
|
async function resolveHosted(input) {
|
|
149
|
-
// Pure managed-cloud shape: `Ablo({schema, apiKey})`.
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
// Pure managed-cloud shape: `Ablo({schema, apiKey})`. The credential policy
|
|
165
|
+
// already exchanged the apiKey (delegating to `exchangeApiKey`); here we apply
|
|
166
|
+
// the returned scope + userMeta and stand up the refresh scheduler.
|
|
167
|
+
const { exchange } = input.cred;
|
|
168
|
+
const baseUrl = input.baseUrl;
|
|
169
|
+
// The refresh path re-runs `exchangeApiKey` with a freshly-resolved apiKey, so
|
|
170
|
+
// it needs the same argument bag the policy used for the initial exchange.
|
|
155
171
|
const exchangeArgs = {
|
|
156
172
|
baseUrl,
|
|
157
173
|
participantKind: (input.kind === 'agent' ? 'agent' : 'system'),
|
|
@@ -159,10 +175,6 @@ async function resolveHosted(input) {
|
|
|
159
175
|
wideScope: true,
|
|
160
176
|
ttlSeconds: 3600,
|
|
161
177
|
};
|
|
162
|
-
const exchange = await exchangeApiKey({
|
|
163
|
-
...exchangeArgs,
|
|
164
|
-
apiKey: input.apiKeyValue,
|
|
165
|
-
});
|
|
166
178
|
input.bootstrapHelper.setCacheScope(exchange.scope.organizationId);
|
|
167
179
|
input.bootstrapHelper.setSyncGroups(exchange.scope.syncGroups);
|
|
168
180
|
input.auth.setAuthToken(exchange.token);
|
package/dist/client/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* });
|
|
30
30
|
* ```
|
|
31
31
|
*/
|
|
32
|
-
export { Ablo, computeFKDepthPriority, type AbloOptions, type InternalAbloOptions, type ClaimedOptions, type IfClaimedPolicy, type ClaimWaitOptions, type
|
|
32
|
+
export { Ablo, computeFKDepthPriority, type AbloOptions, type InternalAbloOptions, type ClaimedOptions, type IfClaimedPolicy, type ClaimWaitOptions, type LocalCountOptions, type LocalReadOptions, type ModelListScope, type ServerReadOptions, type ModelOperations, type ModelReadOptions, } from './Ablo.js';
|
|
33
33
|
export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './auth.js';
|
|
34
34
|
export type { AbloPersistence } from './persistence.js';
|
|
35
35
|
export type { AbloApi, AbloApiClientOptions, AbloApiClaims, Capability, CapabilityCreateOptions, CapabilityParticipantKind, CapabilityRecord, CapabilityResource, CapabilityRevocation, CapabilityScope, } from './ApiClient.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SchemaRecord } from '../schema/schema.js';
|
|
2
|
+
import type { AbloSession, CreateSessionParams } from './Ablo.js';
|
|
3
|
+
/** The resolved control-plane context a mint needs. `fetch` is optional — the
|
|
4
|
+
* auth helpers fall back to the runtime global when omitted. */
|
|
5
|
+
export interface MintSessionContext {
|
|
6
|
+
readonly apiKey: string;
|
|
7
|
+
readonly baseUrl: string;
|
|
8
|
+
readonly fetch?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Mint a session token from an already-resolved `sk_` credential + base URL.
|
|
12
|
+
* Discriminates the `{ user }` / `{ agent }` union onto the server's two mint
|
|
13
|
+
* doors and reshapes each flat response into the `AbloSession` resource.
|
|
14
|
+
*/
|
|
15
|
+
export declare function mintSession<S extends SchemaRecord>(params: CreateSessionParams<S>, ctx: MintSessionContext): Promise<AbloSession>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mintSession` — the ONE implementation behind `sessions.create`, shared by the
|
|
3
|
+
* stateful `Ablo` client and the stateless protocol / HTTP client so the two can
|
|
4
|
+
* never drift on HOW a token is minted.
|
|
5
|
+
*
|
|
6
|
+
* Minting is a pure control-plane HTTP call (no socket, no synced pool): a backend
|
|
7
|
+
* holding a secret `sk_` exchanges it for a short-lived scoped token — `ek_` for an
|
|
8
|
+
* `{ user }` session (full end-user authority) or `rk_` for an `{ agent }` session
|
|
9
|
+
* (scoped to exactly the operations named in `can`). The two arms map to the
|
|
10
|
+
* server's two mint doors:
|
|
11
|
+
*
|
|
12
|
+
* `{ user }` → POST /auth/ephemeral-keys → `ek_`. The user-session door;
|
|
13
|
+
* routing this arm through /auth/capability is structurally
|
|
14
|
+
* impossible — that route rejects participantKind 'user' outright
|
|
15
|
+
* (`invalid_participant_kind`, the 2026-06-11 Pulse cascade where
|
|
16
|
+
* the SDK's own blessed pattern 403'd and integrators fell back to
|
|
17
|
+
* minting humans as agents).
|
|
18
|
+
* `{ agent }` → POST /auth/capability → scoped `rk_`. `can: { tasks: ['update'] }`
|
|
19
|
+
* serializes to the wire allowlist (`tasks.update`); the Hub matches
|
|
20
|
+
* it against every registered alias of the model.
|
|
21
|
+
*
|
|
22
|
+
* The caller supplies the resolved control-plane credential + base URL in `ctx`;
|
|
23
|
+
* WHICH key to use (the original `sk_`, never a derived `rk_` the startup exchange
|
|
24
|
+
* may have installed) is the caller's concern — see the two call sites.
|
|
25
|
+
*
|
|
26
|
+
* Type-only imports of `CreateSessionParams` / `AbloSession` keep this module a
|
|
27
|
+
* leaf (no runtime cycle back to `Ablo.ts`): at runtime it depends on `auth` +
|
|
28
|
+
* `schema` only.
|
|
29
|
+
*/
|
|
30
|
+
import { exchangeApiKey, mintUserSessionKey } from '../auth/index.js';
|
|
31
|
+
/**
|
|
32
|
+
* Mint a session token from an already-resolved `sk_` credential + base URL.
|
|
33
|
+
* Discriminates the `{ user }` / `{ agent }` union onto the server's two mint
|
|
34
|
+
* doors and reshapes each flat response into the `AbloSession` resource.
|
|
35
|
+
*/
|
|
36
|
+
export async function mintSession(params, ctx) {
|
|
37
|
+
const { apiKey, baseUrl } = ctx;
|
|
38
|
+
if (params.user) {
|
|
39
|
+
const res = await mintUserSessionKey({
|
|
40
|
+
apiKey,
|
|
41
|
+
baseUrl,
|
|
42
|
+
userId: params.user.id,
|
|
43
|
+
...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
|
|
44
|
+
ttlSeconds: params.ttlSeconds ?? 900,
|
|
45
|
+
...(ctx.fetch ? { fetch: ctx.fetch } : {}),
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
object: 'session',
|
|
49
|
+
id: res.id,
|
|
50
|
+
token: res.token,
|
|
51
|
+
expiresAt: res.expiresAt,
|
|
52
|
+
organizationId: res.organizationId,
|
|
53
|
+
// The ephemeral mint stores scope on the key row; reshape its flat
|
|
54
|
+
// response into the session resource's scope block.
|
|
55
|
+
scope: {
|
|
56
|
+
organizationId: res.organizationId,
|
|
57
|
+
syncGroups: res.syncGroups,
|
|
58
|
+
operations: [],
|
|
59
|
+
participantKind: 'user',
|
|
60
|
+
participantId: res.participantId,
|
|
61
|
+
},
|
|
62
|
+
userMeta: params.userMeta ?? { id: res.participantId },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
|
|
66
|
+
const res = await exchangeApiKey({
|
|
67
|
+
apiKey,
|
|
68
|
+
baseUrl,
|
|
69
|
+
participantKind: 'agent',
|
|
70
|
+
participantId: params.agent.id,
|
|
71
|
+
...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
|
|
72
|
+
operations,
|
|
73
|
+
ttlSeconds: params.ttlSeconds ?? 900,
|
|
74
|
+
...(params.userMeta ? { userMeta: params.userMeta } : {}),
|
|
75
|
+
...(ctx.fetch ? { fetch: ctx.fetch } : {}),
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
object: 'session',
|
|
79
|
+
id: res.capabilityId,
|
|
80
|
+
token: res.token,
|
|
81
|
+
expiresAt: res.expiresAt,
|
|
82
|
+
organizationId: res.organizationId,
|
|
83
|
+
scope: res.scope,
|
|
84
|
+
userMeta: res.userMeta,
|
|
85
|
+
};
|
|
86
|
+
}
|
package/dist/errorCodes.d.ts
CHANGED
|
@@ -156,6 +156,7 @@ export declare const ERROR_CODES: {
|
|
|
156
156
|
readonly model_claimed: ErrorCodeSpec;
|
|
157
157
|
readonly model_claimed_timeout: ErrorCodeSpec;
|
|
158
158
|
readonly model_claim_not_configured: ErrorCodeSpec;
|
|
159
|
+
readonly model_watch_not_configured: ErrorCodeSpec;
|
|
159
160
|
readonly stale_context: ErrorCodeSpec;
|
|
160
161
|
readonly idempotency_conflict: ErrorCodeSpec;
|
|
161
162
|
readonly idempotency_key_too_long: ErrorCodeSpec;
|
|
@@ -195,6 +196,7 @@ export declare const ERROR_CODES: {
|
|
|
195
196
|
readonly schema_scope_kind_invalid: ErrorCodeSpec;
|
|
196
197
|
readonly schema_field_not_camelcase: ErrorCodeSpec;
|
|
197
198
|
readonly schema_field_consecutive_caps: ErrorCodeSpec;
|
|
199
|
+
readonly schema_reserved_field: ErrorCodeSpec;
|
|
198
200
|
readonly schema_grants_shape_invalid: ErrorCodeSpec;
|
|
199
201
|
readonly schema_grants_identifier_unsafe: ErrorCodeSpec;
|
|
200
202
|
readonly schema_grants_relation_kind: ErrorCodeSpec;
|
package/dist/errorCodes.js
CHANGED
|
@@ -159,6 +159,7 @@ export const ERROR_CODES = {
|
|
|
159
159
|
model_claimed: wire('claim', 409, false, 'The model instance is claimed by another participant.'),
|
|
160
160
|
model_claimed_timeout: wire('claim', 409, false, 'Timed out waiting for a model claim to clear.'),
|
|
161
161
|
model_claim_not_configured: client('claim', 'Claiming requires the collaboration runtime, which the standard Ablo({ schema, apiKey }) client wires up for every model automatically — there is no per-model claim configuration to add. This appears only when a model proxy is constructed directly without that runtime (an internal/advanced path).'),
|
|
162
|
+
model_watch_not_configured: client('claim', 'watch() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport).'),
|
|
162
163
|
// ── stale context / idempotency (409) ──────────────────────────────
|
|
163
164
|
stale_context: wire('conflict', 409, true, 'The write carried a readAt watermark that is now stale; re-read and retry.'),
|
|
164
165
|
idempotency_conflict: wire('conflict', 409, false, 'The same Idempotency-Key was reused with a different request body.'),
|
|
@@ -210,6 +211,7 @@ export const ERROR_CODES = {
|
|
|
210
211
|
schema_scope_kind_invalid: wire('schema', 400, false, 'A scope kind in the schema is invalid.'),
|
|
211
212
|
schema_field_not_camelcase: wire('schema', 400, false, 'A schema field name is not camelCase.'),
|
|
212
213
|
schema_field_consecutive_caps: wire('schema', 400, false, 'A schema field name has consecutive capital letters.'),
|
|
214
|
+
schema_reserved_field: client('schema', 'A model redeclared a reserved base field (id, createdAt, updatedAt, organizationId, createdBy) that the SDK provides automatically.'),
|
|
213
215
|
schema_grants_shape_invalid: wire('schema', 400, false, 'A grants declaration has an invalid shape.'),
|
|
214
216
|
schema_grants_identifier_unsafe: wire('schema', 400, false, 'A grants declaration referenced an unsafe identifier.'),
|
|
215
217
|
schema_grants_relation_kind: wire('schema', 400, false, 'A grants relation referenced an invalid kind.'),
|