@abloatai/ablo 0.11.0 → 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 +58 -0
- package/README.md +72 -25
- 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 +154 -25
- package/dist/client/Ablo.d.ts +39 -88
- package/dist/client/Ablo.js +54 -99
- package/dist/client/ApiClient.d.ts +10 -1
- package/dist/client/ApiClient.js +23 -12
- package/dist/client/auth.d.ts +21 -9
- package/dist/client/auth.js +42 -6
- package/dist/client/createModelProxy.d.ts +74 -10
- package/dist/client/createModelProxy.js +85 -4
- 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 +3 -1
- 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 +16 -5
- 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.js +22 -10
- package/dist/types/global.d.ts +11 -3
- package/dist/types/global.js +8 -3
- 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 +6 -5
- package/docs/client-behavior.md +7 -3
- package/docs/coordination.md +88 -24
- package/docs/data-sources.md +29 -9
- 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 +49 -2
- package/docs/quickstart.md +65 -33
- package/docs/react.md +49 -3
- package/docs/schema-contract.md +23 -5
- package/llms-full.txt +43 -24
- package/llms.txt +17 -15
- 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.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';
|
|
@@ -42,7 +42,7 @@ import { createProtocolClient, } from './ApiClient.js';
|
|
|
42
42
|
// Value import is cycle-safe: httpClient.js only value-imports ApiClient.js,
|
|
43
43
|
// which imports this module type-only.
|
|
44
44
|
import { createAbloHttpClient, } from './httpClient.js';
|
|
45
|
-
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, } from './auth.js';
|
|
45
|
+
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfDatabaseUrlEnvIgnored, } from './auth.js';
|
|
46
46
|
import { registerDataSource } from './registerDataSource.js';
|
|
47
47
|
import { shouldUseInMemoryPersistence, } from './persistence.js';
|
|
48
48
|
import { createModelProxy } from './createModelProxy.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({
|
|
@@ -725,6 +713,9 @@ export function Ablo(options) {
|
|
|
725
713
|
dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
|
|
726
714
|
});
|
|
727
715
|
const { logger = consoleLogger } = internalOptions;
|
|
716
|
+
// Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
|
|
717
|
+
// passed — the env value is no longer auto-adopted (see resolveDatabaseUrl).
|
|
718
|
+
warnIfDatabaseUrlEnvIgnored(authInput, (m) => logger.warn(m));
|
|
728
719
|
const schema = options.schema;
|
|
729
720
|
const url = resolveBaseURL(authInput);
|
|
730
721
|
// 1. Derive config from schema
|
|
@@ -897,7 +888,7 @@ export function Ablo(options) {
|
|
|
897
888
|
// WebSocket upgrade + bootstrap carry a valid bearer (no tokenless first
|
|
898
889
|
// connect that has to self-heal). Only when a refreshing resolver is
|
|
899
890
|
// wired AND no static credential is already present. Contract mirrors
|
|
900
|
-
// `
|
|
891
|
+
// the `apiKey` resolver: `null` ⇒ the login is gone (terminal — fail ready so the
|
|
901
892
|
// app shows sign-in); a THROW ⇒ transient (rethrown; autoStart swallows
|
|
902
893
|
// and the lifecycle's online/wake triggers retry).
|
|
903
894
|
if (credentialResolver && !authCredentials.getAuthToken()) {
|
|
@@ -930,8 +921,9 @@ export function Ablo(options) {
|
|
|
930
921
|
kind,
|
|
931
922
|
configuredApiKey,
|
|
932
923
|
// Resolve identity against the LIVE token, not the construction-time
|
|
933
|
-
// `configuredAuthToken`. Consumers using `
|
|
934
|
-
// 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
|
|
935
927
|
// `ready()`, which updates the shared credential source. Reading the frozen
|
|
936
928
|
// `configuredAuthToken` here made `/auth/identity` fire with no Bearer
|
|
937
929
|
// (→ `no_matching_provider` / `session_expired`) even though the JWT
|
|
@@ -1244,14 +1236,8 @@ export function Ablo(options) {
|
|
|
1244
1236
|
const current = listModelClaims(target);
|
|
1245
1237
|
if (current.length === 0)
|
|
1246
1238
|
return;
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
const queue = listModelClaimQueue(target);
|
|
1250
|
-
if (options?.maxQueueDepth !== undefined &&
|
|
1251
|
-
queue.length >= options.maxQueueDepth) {
|
|
1252
|
-
throw claimedError(target, current, 'queue_too_deep');
|
|
1253
|
-
}
|
|
1254
|
-
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');
|
|
1255
1241
|
}
|
|
1256
1242
|
function wrapClaimHandle(claim, waited = false) {
|
|
1257
1243
|
const release = async () => {
|
|
@@ -1370,6 +1356,26 @@ export function Ablo(options) {
|
|
|
1370
1356
|
},
|
|
1371
1357
|
waitFor: (target, waitOptions) => publicClaims.waitFor({ model: target.model, id: target.id }, waitOptions),
|
|
1372
1358
|
selfParticipantId: participantId,
|
|
1359
|
+
selfParticipantKind: kind,
|
|
1360
|
+
// Read-interest / write-intent enrolment for the typed surface.
|
|
1361
|
+
// `enterScope`/`pinScope` resolve the `{ [schemaKey]: id }` scope
|
|
1362
|
+
// through the SAME resolver the claim path uses, landing this client in
|
|
1363
|
+
// the entity-scoped group the holder's claim presence fans out on.
|
|
1364
|
+
// Return the store promise so the claim write path can AWAIT pinScope
|
|
1365
|
+
// BEFORE acquiring the lease (closing the subscribe-vs-broadcast race);
|
|
1366
|
+
// read-interest callers (`retrieve`/`claim.state`) still `void` it and
|
|
1367
|
+
// stay fire-and-forget. SOFT either way — the store swallows reconcile
|
|
1368
|
+
// errors so read interest never makes a read reject or stall.
|
|
1369
|
+
enterScope: (scope) => store.enterScope(scope),
|
|
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
|
+
}),
|
|
1373
1379
|
});
|
|
1374
1380
|
}
|
|
1375
1381
|
const commits = {
|
|
@@ -1529,6 +1535,9 @@ export function Ablo(options) {
|
|
|
1529
1535
|
* (a wide-scope `rk_` on the hosted path), which control-plane routes
|
|
1530
1536
|
* rightly refuse (e.g. the user-session mint is sk_-gated). Counterpart to
|
|
1531
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`.
|
|
1532
1541
|
*/
|
|
1533
1542
|
async function controlPlaneApiKey() {
|
|
1534
1543
|
return resolveApiKeyValue(configuredApiKey);
|
|
@@ -1549,7 +1558,7 @@ export function Ablo(options) {
|
|
|
1549
1558
|
store.nudgeReconnect();
|
|
1550
1559
|
},
|
|
1551
1560
|
async getAuthToken() {
|
|
1552
|
-
// The live short-lived bearer (set via `setAuthToken
|
|
1561
|
+
// The live short-lived bearer (set via `setAuthToken` / `apiKey`-resolver refresh)
|
|
1553
1562
|
// is the canonical credential; fall back to a configured API key.
|
|
1554
1563
|
//
|
|
1555
1564
|
// This is the SYNC-PLANE token (bootstrap, WS, query HTTP). Control-plane
|
|
@@ -1593,66 +1602,15 @@ export function Ablo(options) {
|
|
|
1593
1602
|
url,
|
|
1594
1603
|
bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
|
|
1595
1604
|
});
|
|
1596
|
-
//
|
|
1597
|
-
//
|
|
1598
|
-
//
|
|
1599
|
-
//
|
|
1600
|
-
|
|
1601
|
-
// (`invalid_participant_kind`, the 2026-06-11 Pulse
|
|
1602
|
-
// cascade: the SDK's own blessed pattern 403'd and
|
|
1603
|
-
// integrators fell back to minting humans as agents).
|
|
1604
|
-
// `{ agent }` → POST /auth/capability → scoped `rk_`.
|
|
1605
|
-
// `can: { tasks: ['update'] }` serializes to the wire
|
|
1606
|
-
// allowlist (`tasks.update`); the Hub matches it
|
|
1607
|
-
// against every registered alias of the model.
|
|
1608
|
-
if (params.user) {
|
|
1609
|
-
const res = await mintUserSessionKey({
|
|
1610
|
-
apiKey,
|
|
1611
|
-
baseUrl,
|
|
1612
|
-
userId: params.user.id,
|
|
1613
|
-
...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
|
|
1614
|
-
ttlSeconds: params.ttlSeconds ?? 900,
|
|
1615
|
-
...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
|
|
1616
|
-
});
|
|
1617
|
-
return {
|
|
1618
|
-
object: 'session',
|
|
1619
|
-
id: res.id,
|
|
1620
|
-
token: res.token,
|
|
1621
|
-
expiresAt: res.expiresAt,
|
|
1622
|
-
organizationId: res.organizationId,
|
|
1623
|
-
// The ephemeral mint stores scope on the key row; reshape its flat
|
|
1624
|
-
// response into the session resource's scope block.
|
|
1625
|
-
scope: {
|
|
1626
|
-
organizationId: res.organizationId,
|
|
1627
|
-
syncGroups: res.syncGroups,
|
|
1628
|
-
operations: [],
|
|
1629
|
-
participantKind: 'user',
|
|
1630
|
-
participantId: res.participantId,
|
|
1631
|
-
},
|
|
1632
|
-
userMeta: params.userMeta ?? { id: res.participantId },
|
|
1633
|
-
};
|
|
1634
|
-
}
|
|
1635
|
-
const operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
|
|
1636
|
-
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, {
|
|
1637
1610
|
apiKey,
|
|
1638
1611
|
baseUrl,
|
|
1639
|
-
participantKind: 'agent',
|
|
1640
|
-
participantId: params.agent.id,
|
|
1641
|
-
...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
|
|
1642
|
-
operations,
|
|
1643
|
-
ttlSeconds: params.ttlSeconds ?? 900,
|
|
1644
|
-
...(params.userMeta ? { userMeta: params.userMeta } : {}),
|
|
1645
1612
|
...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
|
|
1646
1613
|
});
|
|
1647
|
-
return {
|
|
1648
|
-
object: 'session',
|
|
1649
|
-
id: res.capabilityId,
|
|
1650
|
-
token: res.token,
|
|
1651
|
-
expiresAt: res.expiresAt,
|
|
1652
|
-
organizationId: res.organizationId,
|
|
1653
|
-
scope: res.scope,
|
|
1654
|
-
userMeta: res.userMeta,
|
|
1655
|
-
};
|
|
1656
1614
|
},
|
|
1657
1615
|
},
|
|
1658
1616
|
async dispose() {
|
|
@@ -1721,9 +1679,6 @@ export function Ablo(options) {
|
|
|
1721
1679
|
claims: publicClaims,
|
|
1722
1680
|
commits,
|
|
1723
1681
|
model,
|
|
1724
|
-
/** Structured multiplayer participation — target-first, no
|
|
1725
|
-
* sync-group strings in the common path. */
|
|
1726
|
-
participants: participantManager,
|
|
1727
1682
|
/** Context-staleness snapshot — see `engine.snapshot(...)` JSDoc. */
|
|
1728
1683
|
snapshot(entities) {
|
|
1729
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
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* nouns directly to HTTP routes on sync-server.
|
|
7
7
|
*/
|
|
8
8
|
import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, claimedError, translateHttpError, } from '../errors.js';
|
|
9
|
-
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, } from './auth.js';
|
|
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) {
|
|
@@ -17,6 +18,9 @@ export function createProtocolClient(options) {
|
|
|
17
18
|
const configuredApiKey = resolveApiKey(authInput);
|
|
18
19
|
const configuredAuthToken = resolveAuthToken(authInput);
|
|
19
20
|
const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
|
|
21
|
+
// Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
|
|
22
|
+
// passed — no logger on this path, so the helper falls back to console.warn.
|
|
23
|
+
warnIfDatabaseUrlEnvIgnored(authInput);
|
|
20
24
|
assertBrowserSafety({
|
|
21
25
|
apiKey: configuredApiKey,
|
|
22
26
|
databaseUrl: configuredDatabaseUrl,
|
|
@@ -225,20 +229,11 @@ export function createProtocolClient(options) {
|
|
|
225
229
|
const policy = options?.ifClaimed ?? defaultPolicy;
|
|
226
230
|
if (policy === 'return')
|
|
227
231
|
return;
|
|
232
|
+
// policy === 'fail' — gate the read only when the caller opts in.
|
|
228
233
|
const state = await listClaimState(target);
|
|
229
234
|
if (state.active.length === 0)
|
|
230
235
|
return;
|
|
231
|
-
|
|
232
|
-
throw claimedError(target, state.active, 'model_claimed');
|
|
233
|
-
}
|
|
234
|
-
if (options?.maxQueueDepth !== undefined &&
|
|
235
|
-
state.queue.length >= options.maxQueueDepth) {
|
|
236
|
-
throw claimedError(target, state.active, 'queue_too_deep');
|
|
237
|
-
}
|
|
238
|
-
await waitForNoClaims(target, {
|
|
239
|
-
timeout: options?.claimedTimeout,
|
|
240
|
-
pollInterval: options?.claimedPollInterval,
|
|
241
|
-
});
|
|
236
|
+
throw claimedError(target, state.active, 'model_claimed');
|
|
242
237
|
}
|
|
243
238
|
const commits = {
|
|
244
239
|
async create(commitOptions) {
|
|
@@ -653,6 +648,22 @@ export function createProtocolClient(options) {
|
|
|
653
648
|
claims,
|
|
654
649
|
commits,
|
|
655
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
|
+
},
|
|
656
667
|
async getAuthToken() {
|
|
657
668
|
// Mirror `authHeaders()`: a configured API key wins, else the
|
|
658
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
|
|
@@ -45,12 +52,17 @@ export declare function resolveAuthToken(input: AuthResolveInput): string | null
|
|
|
45
52
|
/**
|
|
46
53
|
* Resolve the direct-URL connector's Postgres connection string.
|
|
47
54
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
55
|
+
* `databaseUrl` is an EXPLICIT, opt-in option: Ablo registers a dedicated
|
|
56
|
+
* tenant database only when the caller passes it to `Ablo(...)`. It is NOT
|
|
57
|
+
* read from `process.env.DATABASE_URL` — per this module's invariant
|
|
58
|
+
* (`ABLO_API_KEY` is the only environment fallback), an app's `DATABASE_URL`
|
|
59
|
+
* (commonly set for Prisma/Drizzle/docker) must never silently flip the client
|
|
60
|
+
* into connection-string mode. The default Data Source path keeps `DATABASE_URL`
|
|
61
|
+
* in the app and exposes `dataSource(...)`; that path leaves this null.
|
|
62
|
+
* `warnIfDatabaseUrlEnvIgnored` nudges callers who set the env but omitted the option.
|
|
52
63
|
*/
|
|
53
64
|
export declare function resolveDatabaseUrl(input: AuthResolveInput): string | null;
|
|
65
|
+
export declare function warnIfDatabaseUrlEnvIgnored(input: AuthResolveInput, warn?: (message: string) => void): void;
|
|
54
66
|
export declare const ABLO_HOSTED_API_DOMAIN = "api.abloatai.com";
|
|
55
67
|
export declare const ABLO_HOSTED_HTTP_BASE_URL = "https://api.abloatai.com";
|
|
56
68
|
export declare const ABLO_DEFAULT_BASE_URL = "https://api.abloatai.com";
|
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
|
|
@@ -30,13 +31,48 @@ export function resolveAuthToken(input) {
|
|
|
30
31
|
/**
|
|
31
32
|
* Resolve the direct-URL connector's Postgres connection string.
|
|
32
33
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* `databaseUrl` is an EXPLICIT, opt-in option: Ablo registers a dedicated
|
|
35
|
+
* tenant database only when the caller passes it to `Ablo(...)`. It is NOT
|
|
36
|
+
* read from `process.env.DATABASE_URL` — per this module's invariant
|
|
37
|
+
* (`ABLO_API_KEY` is the only environment fallback), an app's `DATABASE_URL`
|
|
38
|
+
* (commonly set for Prisma/Drizzle/docker) must never silently flip the client
|
|
39
|
+
* into connection-string mode. The default Data Source path keeps `DATABASE_URL`
|
|
40
|
+
* in the app and exposes `dataSource(...)`; that path leaves this null.
|
|
41
|
+
* `warnIfDatabaseUrlEnvIgnored` nudges callers who set the env but omitted the option.
|
|
37
42
|
*/
|
|
38
43
|
export function resolveDatabaseUrl(input) {
|
|
39
|
-
return input.options.databaseUrl ??
|
|
44
|
+
return input.options.databaseUrl ?? null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* One-time migration nudge for the dropped `DATABASE_URL` env fallback.
|
|
48
|
+
*
|
|
49
|
+
* Earlier versions silently adopted `process.env.DATABASE_URL` when `databaseUrl`
|
|
50
|
+
* was not passed, registering a direct connector behind the caller's back — which
|
|
51
|
+
* surprised any app that keeps `DATABASE_URL` for another tool (Prisma, Drizzle,
|
|
52
|
+
* docker-compose) and, on localhost, tried to register a database Ablo's cloud
|
|
53
|
+
* cannot reach. The env value is now ignored; this points the developer at the
|
|
54
|
+
* explicit option instead of flipping their mode for them. Warns once per process
|
|
55
|
+
* so it never spams, and falls back to `console.warn` when no logger is supplied
|
|
56
|
+
* (the `transport: 'api'` client has none).
|
|
57
|
+
*/
|
|
58
|
+
let warnedDatabaseUrlEnvIgnored = false;
|
|
59
|
+
export function warnIfDatabaseUrlEnvIgnored(input, warn) {
|
|
60
|
+
if (warnedDatabaseUrlEnvIgnored)
|
|
61
|
+
return;
|
|
62
|
+
if (input.options.databaseUrl != null)
|
|
63
|
+
return;
|
|
64
|
+
const envUrl = input.env.DATABASE_URL;
|
|
65
|
+
if (typeof envUrl !== 'string' || envUrl.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
warnedDatabaseUrlEnvIgnored = true;
|
|
68
|
+
const message = 'Found DATABASE_URL in the environment but `databaseUrl` was not passed to Ablo(...). ' +
|
|
69
|
+
'Ablo no longer auto-adopts DATABASE_URL — the environment value is ignored. ' +
|
|
70
|
+
'To register your Postgres directly, pass `databaseUrl: process.env.DATABASE_URL` explicitly; ' +
|
|
71
|
+
'otherwise ignore this (the hosted sandbox and signed Data Source endpoints need no databaseUrl).';
|
|
72
|
+
if (warn)
|
|
73
|
+
warn(message);
|
|
74
|
+
else if (typeof console !== 'undefined')
|
|
75
|
+
console.warn('[sync]', message);
|
|
40
76
|
}
|
|
41
77
|
export const ABLO_HOSTED_API_DOMAIN = 'api.abloatai.com';
|
|
42
78
|
export const ABLO_HOSTED_HTTP_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
|
|
@@ -102,7 +138,7 @@ export function assertBrowserSafety(input) {
|
|
|
102
138
|
if (!input.dangerouslyAllowBrowser &&
|
|
103
139
|
inBrowser &&
|
|
104
140
|
typeof input.apiKey === 'string' &&
|
|
105
|
-
input.apiKey
|
|
141
|
+
classifyCredentialKind(input.apiKey) === 'secret') {
|
|
106
142
|
throw new AbloAuthenticationError("It looks like you're running in a browser-like environment.\n\n" +
|
|
107
143
|
'This is disabled by default — your secret API key would be ' +
|
|
108
144
|
"exposed to every visitor's network tab. If you understand the risks " +
|
|
@@ -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: {
|
|
@@ -136,6 +143,39 @@ export interface ModelCollaboration<T> {
|
|
|
136
143
|
* from "someone else holds it" in `claimOrWait`.
|
|
137
144
|
*/
|
|
138
145
|
readonly selfParticipantId: string;
|
|
146
|
+
/**
|
|
147
|
+
* The local participant's kind (`'user' | 'agent' | 'system'`). Used to
|
|
148
|
+
* stamp the synthesized self-claim returned from `claim.state` when the
|
|
149
|
+
* LOCAL proxy holds the lease (server presence frames exclude one's own
|
|
150
|
+
* claims, so the holder must build its own view).
|
|
151
|
+
*/
|
|
152
|
+
readonly selfParticipantKind?: 'user' | 'agent' | 'system';
|
|
153
|
+
/**
|
|
154
|
+
* Subscribe the connection to a scope's sync group(s) (read-interest).
|
|
155
|
+
* The typed surface calls this on single-entity reads/claim observation so
|
|
156
|
+
* a Node/agent client lands in the SAME entity-scoped group the holder's
|
|
157
|
+
* claim presence fans out on — otherwise a peer subscribed only to
|
|
158
|
+
* `org:`/`user:` groups never sees claim broadcasts. Fire-and-forget and
|
|
159
|
+
* SOFT: read interest is best-effort and must never make a read reject or
|
|
160
|
+
* stall (see `AreaOfInterestManager.reconcile`). Optional so minimal test
|
|
161
|
+
* doubles can omit it. Forwards to `BaseSyncedStore.enterScope`.
|
|
162
|
+
*/
|
|
163
|
+
enterScope?(scope: Record<string, string>): void | Promise<void>;
|
|
164
|
+
/**
|
|
165
|
+
* Pin a scope's sync group(s) (write-intent / prominence): a row this
|
|
166
|
+
* client holds an active claim on stays subscribed regardless of
|
|
167
|
+
* navigation. Same fire-and-forget, soft semantics as `enterScope`.
|
|
168
|
+
* Forwards to `BaseSyncedStore.pinScope`.
|
|
169
|
+
*/
|
|
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>;
|
|
139
179
|
}
|
|
140
180
|
export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
141
181
|
/** Phase shown to observers while held. Defaults to `'editing'`. */
|
|
@@ -251,7 +291,7 @@ export interface ClaimApi<T> {
|
|
|
251
291
|
/** Release a manual claim handle early. Single-write claims auto-release. */
|
|
252
292
|
release(params: ClaimLookupParams<T> | ClaimHandle<T>): Promise<void>;
|
|
253
293
|
}
|
|
254
|
-
export interface ModelRetrieveParams extends
|
|
294
|
+
export interface ModelRetrieveParams extends ServerRetrieveOptions {
|
|
255
295
|
readonly id: string;
|
|
256
296
|
}
|
|
257
297
|
export interface ModelCreateParams<T, CreateInput> extends MutationOptions {
|
|
@@ -268,6 +308,15 @@ export interface ModelDeleteParams<T> extends MutationOptions {
|
|
|
268
308
|
readonly id: string;
|
|
269
309
|
readonly claim?: ClaimHandle<T> | ClaimTargetOptions<T> | null;
|
|
270
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
|
+
}
|
|
271
320
|
export interface ModelOperations<T, CreateInput> {
|
|
272
321
|
/**
|
|
273
322
|
* Read a single entity by id from the **server** — async. Resolves through
|
|
@@ -291,7 +340,7 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
291
340
|
* Mirrors `stripe.customers.list({...})` — network-backed. For a synchronous
|
|
292
341
|
* read of the local graph use `getAll(...)`.
|
|
293
342
|
*/
|
|
294
|
-
list(options?:
|
|
343
|
+
list(options?: ServerReadOptions<T>): Promise<T[]>;
|
|
295
344
|
/**
|
|
296
345
|
* Synchronous snapshot of a single entity from the **local graph** — no
|
|
297
346
|
* network. Returns `undefined` when the row isn't resident (cold hosted
|
|
@@ -304,9 +353,9 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
304
353
|
* no network round-trip. Empty until `retrieve`/`list`/bootstrap has warmed
|
|
305
354
|
* the graph.
|
|
306
355
|
*/
|
|
307
|
-
getAll(options?:
|
|
356
|
+
getAll(options?: LocalReadOptions<T>): T[];
|
|
308
357
|
/** Count entities in the **local graph** (synchronous, no network). */
|
|
309
|
-
getCount(options?:
|
|
358
|
+
getCount(options?: LocalCountOptions<T>): number;
|
|
310
359
|
/**
|
|
311
360
|
* Create a new entity — **optimistic, offline-first**. Resolves once
|
|
312
361
|
* the mutation is queued locally, not when the server confirms.
|
|
@@ -345,7 +394,22 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
345
394
|
* ```
|
|
346
395
|
*/
|
|
347
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>;
|
|
348
412
|
/** Listen for changes (callback called on every change). */
|
|
349
|
-
onChange(callback: (entities: T[]) => void, options?:
|
|
413
|
+
onChange(callback: (entities: T[]) => void, options?: LocalReadOptions<T>): () => void;
|
|
350
414
|
}
|
|
351
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>;
|