@abloatai/ablo 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/README.md +95 -57
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +8 -4
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +3 -2
- package/dist/auth/index.js +39 -11
- package/dist/client/Ablo.d.ts +112 -3
- package/dist/client/Ablo.js +144 -10
- package/dist/client/ApiClient.d.ts +32 -0
- package/dist/client/ApiClient.js +76 -44
- package/dist/client/auth.d.ts +11 -1
- package/dist/client/auth.js +21 -2
- package/dist/client/createModelProxy.d.ts +120 -53
- package/dist/client/createModelProxy.js +66 -31
- package/dist/client/identity.js +14 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +57 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +286 -0
- package/dist/errorCodes.js +284 -0
- package/dist/errors.d.ts +103 -7
- package/dist/errors.js +192 -41
- package/dist/index.d.ts +11 -6
- package/dist/index.js +10 -6
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +37 -0
- package/dist/react/AbloProvider.js +107 -4
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +6 -0
- package/dist/schema/diff.js +21 -3
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/index.d.ts +7 -4
- package/dist/schema/index.js +9 -3
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +2 -112
- package/dist/schema/schema.js +50 -62
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +16 -12
- package/dist/schema/serialize.js +16 -12
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/BootstrapHelper.js +46 -27
- package/dist/sync/ConnectionManager.d.ts +3 -1
- package/dist/sync/ConnectionManager.js +37 -1
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +26 -19
- package/dist/sync/NetworkProbe.d.ts +8 -0
- package/dist/sync/NetworkProbe.js +24 -2
- package/dist/sync/SyncWebSocket.d.ts +1 -1
- package/dist/sync/SyncWebSocket.js +43 -53
- package/dist/sync/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +10 -16
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +47 -3
- package/docs/api.md +103 -57
- package/docs/audit.md +16 -9
- package/docs/cli.md +222 -0
- package/docs/client-behavior.md +35 -21
- package/docs/coordination.md +74 -36
- package/docs/data-sources.md +23 -21
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +30 -19
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +93 -0
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +29 -17
- package/docs/identity.md +198 -121
- package/docs/index.md +35 -18
- package/docs/integration-guide.md +79 -83
- package/docs/interaction-model.md +40 -25
- package/docs/mcp/claude-code.md +9 -17
- package/docs/mcp/cursor.md +6 -24
- package/docs/mcp/windsurf.md +6 -19
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +31 -39
- package/docs/react.md +18 -14
- package/docs/roadmap.md +15 -3
- package/docs/schema-contract.md +109 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +6 -2
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +27 -16
- package/package.json +13 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Ablo API-key format — the single source of truth for how keys
|
|
3
|
+
* are minted, hashed, and validated. Both the sync-server (`apiKeyStore`)
|
|
4
|
+
* and the web control-plane (`generate-key.ts`) consume THIS module, so the
|
|
5
|
+
* format can no longer drift between the two mint sites (it used to live as
|
|
6
|
+
* a hand-copied twin kept in sync by a comment).
|
|
7
|
+
*
|
|
8
|
+
* Node-only — uses `node:crypto`. Exposed via the `@abloatai/ablo/keys`
|
|
9
|
+
* subpath and NEVER re-exported from the browser-facing `.` entry, so the
|
|
10
|
+
* client bundle never pulls in `node:crypto`.
|
|
11
|
+
*
|
|
12
|
+
* Format (GitHub-style): `<sk|rk|ek>_<live|test>_<30 base62 body><6-char
|
|
13
|
+
* base62 CRC32 checksum>`. The identifiable prefix + CRC32 checksum let
|
|
14
|
+
* secret scanners detect leaks and let us reject typo'd/forged keys OFFLINE
|
|
15
|
+
* (no DB round-trip). Legacy keys (a ~43-char base64url body, no checksum)
|
|
16
|
+
* still validate by hash — they parse here as `checksummed: false`.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
// ── Vocabulary ──────────────────────────────────────────────────────────
|
|
21
|
+
// The three-key Stripe model:
|
|
22
|
+
// secret (sk_) — backend / server-to-server / agents. Full authority. Never in a browser.
|
|
23
|
+
// restricted (rk_) — scoped SERVER key (agent session tokens / capabilities).
|
|
24
|
+
// ephemeral (ek_) — short-lived, backend-minted, USER-scoped BROWSER session credential
|
|
25
|
+
// (Stripe ephemeral keys). Carries participantKind:'user' + baked syncGroups.
|
|
26
|
+
// (There is no publishable `pk_` — the minted session token, not a project
|
|
27
|
+
// identifier, is what the browser holds; it already names the org.)
|
|
28
|
+
export const API_KEY_KINDS = ['secret', 'restricted', 'ephemeral'];
|
|
29
|
+
export const API_KEY_ENVS = ['live', 'test'];
|
|
30
|
+
const PREFIX_BY_KIND = {
|
|
31
|
+
secret: 'sk',
|
|
32
|
+
restricted: 'rk',
|
|
33
|
+
ephemeral: 'ek',
|
|
34
|
+
};
|
|
35
|
+
const KIND_BY_PREFIX = {
|
|
36
|
+
sk: 'secret',
|
|
37
|
+
rk: 'restricted',
|
|
38
|
+
ek: 'ephemeral',
|
|
39
|
+
};
|
|
40
|
+
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
41
|
+
/** Random base62 chars before the checksum. */
|
|
42
|
+
const KEY_BODY_LEN = 30;
|
|
43
|
+
/** base62(CRC32): 62^6 (~5.7e10) > 2^32, so a CRC32 always fits in 6 chars. */
|
|
44
|
+
const CHECKSUM_LEN = 6;
|
|
45
|
+
/** A new checksummed body is exactly this long and pure base62. */
|
|
46
|
+
const CHECKSUMMED_BODY_LEN = KEY_BODY_LEN + CHECKSUM_LEN;
|
|
47
|
+
/** `<sk|rk|ek>_<live|test>_<body>`; body charset covers base62 AND legacy base64url. */
|
|
48
|
+
const KEY_RE = /^(sk|rk|ek)_(live|test)_([0-9A-Za-z\-_]+)$/;
|
|
49
|
+
const BASE62_RE = /^[0-9A-Za-z]+$/;
|
|
50
|
+
// ── Checksum (standard CRC-32, GitHub-compatible) ───────────────────────
|
|
51
|
+
const CRC32_TABLE = (() => {
|
|
52
|
+
const t = new Uint32Array(256);
|
|
53
|
+
for (let n = 0; n < 256; n++) {
|
|
54
|
+
let c = n;
|
|
55
|
+
for (let k = 0; k < 8; k++)
|
|
56
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
57
|
+
t[n] = c >>> 0;
|
|
58
|
+
}
|
|
59
|
+
return t;
|
|
60
|
+
})();
|
|
61
|
+
function crc32(s) {
|
|
62
|
+
let c = 0xffffffff;
|
|
63
|
+
for (let i = 0; i < s.length; i++) {
|
|
64
|
+
c = (CRC32_TABLE[(c ^ s.charCodeAt(i)) & 0xff] ^ (c >>> 8)) >>> 0;
|
|
65
|
+
}
|
|
66
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
67
|
+
}
|
|
68
|
+
/** 6-char base62 encoding of the CRC32 of `payload`. */
|
|
69
|
+
function checksum6(payload) {
|
|
70
|
+
let n = crc32(payload);
|
|
71
|
+
let out = '';
|
|
72
|
+
for (let i = 0; i < CHECKSUM_LEN; i++) {
|
|
73
|
+
out = BASE62[n % 62] + out;
|
|
74
|
+
n = Math.floor(n / 62);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** `len` cryptographically-random base62 chars (rejection-sampled, no bias). */
|
|
79
|
+
function randomBase62(len) {
|
|
80
|
+
let out = '';
|
|
81
|
+
while (out.length < len) {
|
|
82
|
+
for (const b of randomBytes(len * 2)) {
|
|
83
|
+
if (b < 248) {
|
|
84
|
+
out += BASE62[b % 62];
|
|
85
|
+
if (out.length === len)
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
function bodyIsChecksummed(body) {
|
|
93
|
+
return body.length === CHECKSUMMED_BODY_LEN && BASE62_RE.test(body);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Canonical schema for an Ablo API key. `parse`/`safeParse` returns a typed
|
|
97
|
+
* {@link ParsedApiKey}; a new checksummed-format key with a BAD checksum is
|
|
98
|
+
* rejected (the offline-reject), while a legacy key parses as
|
|
99
|
+
* `checksummed: false` and passes (the server still hash-validates it).
|
|
100
|
+
*/
|
|
101
|
+
export const apiKeySchema = z.string().transform((raw, ctx) => {
|
|
102
|
+
const m = KEY_RE.exec(raw);
|
|
103
|
+
if (!m) {
|
|
104
|
+
ctx.addIssue({ code: 'custom', message: 'not a valid Ablo API key format' });
|
|
105
|
+
return z.NEVER;
|
|
106
|
+
}
|
|
107
|
+
const [, prefix, env, body] = m;
|
|
108
|
+
const checksummed = bodyIsChecksummed(body);
|
|
109
|
+
if (checksummed && checksum6(raw.slice(0, -CHECKSUM_LEN)) !== body.slice(KEY_BODY_LEN)) {
|
|
110
|
+
ctx.addIssue({ code: 'custom', message: 'API key checksum mismatch' });
|
|
111
|
+
return z.NEVER;
|
|
112
|
+
}
|
|
113
|
+
return { raw, kind: KIND_BY_PREFIX[prefix], env: env, body, checksummed };
|
|
114
|
+
});
|
|
115
|
+
// ── Derived validators (thin wrappers over the same spec) ───────────────
|
|
116
|
+
/** Parse + fully validate (incl. checksum). Returns null when invalid. */
|
|
117
|
+
export function parseApiKey(raw) {
|
|
118
|
+
const r = apiKeySchema.safeParse(raw);
|
|
119
|
+
return r.success ? r.data : null;
|
|
120
|
+
}
|
|
121
|
+
/** True when the key uses the new checksummed format (regardless of validity). */
|
|
122
|
+
export function isChecksummedKey(raw) {
|
|
123
|
+
const m = KEY_RE.exec(raw);
|
|
124
|
+
return m !== null && bodyIsChecksummed(m[3]);
|
|
125
|
+
}
|
|
126
|
+
/** Verify the embedded checksum. Meaningful only for checksummed-format keys. */
|
|
127
|
+
export function keyChecksumMatches(raw) {
|
|
128
|
+
const m = KEY_RE.exec(raw);
|
|
129
|
+
if (!m || !bodyIsChecksummed(m[3]))
|
|
130
|
+
return false;
|
|
131
|
+
return checksum6(raw.slice(0, -CHECKSUM_LEN)) === m[3].slice(KEY_BODY_LEN);
|
|
132
|
+
}
|
|
133
|
+
// ── Mint + hash (node:crypto) ───────────────────────────────────────────
|
|
134
|
+
/**
|
|
135
|
+
* Mint a key: `<prefix>_<env>_<body><checksum>`. Returns the plaintext (shown
|
|
136
|
+
* once), its SHA-256 hash (persisted), and the 12-char display prefix.
|
|
137
|
+
*/
|
|
138
|
+
export function generateApiKey(env = 'live', kind = 'secret') {
|
|
139
|
+
const body = randomBase62(KEY_BODY_LEN);
|
|
140
|
+
const payload = `${PREFIX_BY_KIND[kind]}_${env}_${body}`;
|
|
141
|
+
const plaintext = `${payload}${checksum6(payload)}`;
|
|
142
|
+
return { plaintext, hash: hashApiKey(plaintext), prefix: plaintext.slice(0, 12) };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Stable SHA-256 hex of a plaintext key. A fast hash is CORRECT here (not
|
|
146
|
+
* bcrypt) — API keys are high-entropy random, so there's no dictionary to
|
|
147
|
+
* defend against. Used at both write (mint) and lookup.
|
|
148
|
+
*/
|
|
149
|
+
export function hashApiKey(plaintext) {
|
|
150
|
+
return createHash('sha256').update(plaintext).digest('hex');
|
|
151
|
+
}
|
package/dist/policy/index.d.ts
CHANGED
|
@@ -16,4 +16,4 @@
|
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
18
|
export type { Conflict, ConflictDecision, ConflictKind, ConflictOperation, ConflictPolicy, StaleContextConflict, IntentHeldConflict, } from './types.js';
|
|
19
|
-
export { defaultPolicy } from './types.js';
|
|
19
|
+
export { defaultPolicy, capabilityPreemptPolicy } from './types.js';
|
package/dist/policy/index.js
CHANGED
package/dist/policy/types.d.ts
CHANGED
|
@@ -48,6 +48,14 @@ export interface IntentHeldConflict extends ConflictBase {
|
|
|
48
48
|
readonly entityId: string;
|
|
49
49
|
/** Holder's intent expiry (ms since epoch). */
|
|
50
50
|
readonly expiresAt: number;
|
|
51
|
+
/**
|
|
52
|
+
* The committer's granted capability operations (the key's allowlist). A
|
|
53
|
+
* policy is a pure function of the conflict value, so it can only authorize
|
|
54
|
+
* on what's carried here — this is what lets a policy express "preempt iff
|
|
55
|
+
* the committer holds `intent.preempt`" (see `capabilityPreemptPolicy`).
|
|
56
|
+
* Empty for a human session with no allowlist.
|
|
57
|
+
*/
|
|
58
|
+
readonly committerOperations: readonly string[];
|
|
51
59
|
}
|
|
52
60
|
/**
|
|
53
61
|
* The discriminated union the policy receives. Switch on `.kind` to
|
|
@@ -61,6 +69,20 @@ export type ConflictDecision = {
|
|
|
61
69
|
} | {
|
|
62
70
|
readonly action: 'allow';
|
|
63
71
|
readonly note?: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Evict the current holder and grant the target to the committer. Only
|
|
75
|
+
* meaningful for an `intent_held` conflict at claim time (`intent_begin`):
|
|
76
|
+
* the holder receives an `intent_lost` (reason `'preempted'`) and the
|
|
77
|
+
* preemptor takes the lease, jumping ahead of any FIFO waiters. This is the
|
|
78
|
+
* authorization seam for preemption — a policy returns `preempt` only for a
|
|
79
|
+
* committer it deems higher-priority (e.g. a supervisor over its sub-agents,
|
|
80
|
+
* or an identity holding a preempt capability). At commit time there is no
|
|
81
|
+
* holder to evict, so a `preempt` decision there is treated as `allow`.
|
|
82
|
+
*/
|
|
83
|
+
| {
|
|
84
|
+
readonly action: 'preempt';
|
|
85
|
+
readonly reason?: string;
|
|
64
86
|
};
|
|
65
87
|
/**
|
|
66
88
|
* Pluggable decision function. Sync or async.
|
|
@@ -81,4 +103,13 @@ export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<
|
|
|
81
103
|
* intent-conflicting write through.
|
|
82
104
|
*/
|
|
83
105
|
export declare const defaultPolicy: ConflictPolicy;
|
|
106
|
+
/**
|
|
107
|
+
* Capability-gated preemption. An `intent_held` conflict is PREEMPTED when the
|
|
108
|
+
* committer holds the `intent.preempt` operation in its capability allowlist
|
|
109
|
+
* (the holder is evicted, the committer takes the lease); everything else falls
|
|
110
|
+
* back to `defaultPolicy` (reject). Opt-in — wire it as a `conflictPolicies`
|
|
111
|
+
* global to let a privileged identity jump a held entity without a bespoke
|
|
112
|
+
* policy. The authorization is the capability, not an identity string.
|
|
113
|
+
*/
|
|
114
|
+
export declare const capabilityPreemptPolicy: ConflictPolicy;
|
|
84
115
|
export {};
|
package/dist/policy/types.js
CHANGED
|
@@ -15,3 +15,18 @@ export const defaultPolicy = (conflict) => ({
|
|
|
15
15
|
action: 'reject',
|
|
16
16
|
reason: conflict.kind === 'stale_context' ? 'stale_context' : 'intent_conflict',
|
|
17
17
|
});
|
|
18
|
+
/**
|
|
19
|
+
* Capability-gated preemption. An `intent_held` conflict is PREEMPTED when the
|
|
20
|
+
* committer holds the `intent.preempt` operation in its capability allowlist
|
|
21
|
+
* (the holder is evicted, the committer takes the lease); everything else falls
|
|
22
|
+
* back to `defaultPolicy` (reject). Opt-in — wire it as a `conflictPolicies`
|
|
23
|
+
* global to let a privileged identity jump a held entity without a bespoke
|
|
24
|
+
* policy. The authorization is the capability, not an identity string.
|
|
25
|
+
*/
|
|
26
|
+
export const capabilityPreemptPolicy = (conflict) => {
|
|
27
|
+
if (conflict.kind === 'intent_held' &&
|
|
28
|
+
conflict.committerOperations.includes('intent.preempt')) {
|
|
29
|
+
return { action: 'preempt', reason: 'capability:intent.preempt' };
|
|
30
|
+
}
|
|
31
|
+
return defaultPolicy(conflict);
|
|
32
|
+
};
|
package/dist/query/client.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* without duplicating the fetch boilerplate.
|
|
13
13
|
*/
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
+
import { translateHttpError } from '../errors.js';
|
|
15
16
|
// ── Response validation ─────────────────────────────────────────────────
|
|
16
17
|
//
|
|
17
18
|
// Each result slot is an array of rows (or an object for bundled
|
|
@@ -60,14 +61,24 @@ export async function postQuery(options, batch) {
|
|
|
60
61
|
signal: controller.signal,
|
|
61
62
|
});
|
|
62
63
|
if (!response.ok) {
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
64
|
+
// Build the typed AbloError for this HTTP failure (same code→class
|
|
65
|
+
// map the throwing paths use) so the log is tagged + carries a
|
|
66
|
+
// registry `code` (e.g. AbloAuthenticationError/session_expired on a
|
|
67
|
+
// 401) instead of a bare status. We deliberately DON'T throw —
|
|
68
|
+
// fire-and-forget callers would kill the Next.js router on an
|
|
69
|
+
// unhandled rejection — and still return empty slots, but the failure
|
|
70
|
+
// is now legible as an Ablo error. Direct console.error is
|
|
71
|
+
// INTENTIONAL: operators alert on the `[postQuery.error]` prefix.
|
|
72
|
+
let body = null;
|
|
73
|
+
try {
|
|
74
|
+
body = await response.clone().json();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// non-JSON error page — translateHttpError falls back to status text
|
|
78
|
+
}
|
|
79
|
+
const err = translateHttpError(response.status, body);
|
|
80
|
+
console.error(`[postQuery.error] ${err.type} ${err.code ?? response.status} for ` +
|
|
81
|
+
`${batch.queries.map((q) => q.model).join(',')}: ${err.message}`);
|
|
71
82
|
return { results: batch.queries.map(() => []) };
|
|
72
83
|
}
|
|
73
84
|
const raw = await response.json();
|
|
@@ -72,6 +72,31 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
|
|
|
72
72
|
* same-origin session cookies.
|
|
73
73
|
*/
|
|
74
74
|
apiKey?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Static bearer auth token, sent as `Authorization: Bearer <token>` on the
|
|
77
|
+
* WebSocket upgrade + HTTP. For a token that must be refreshed (e.g. a
|
|
78
|
+
* short-lived JWT), prefer {@link getToken}.
|
|
79
|
+
*/
|
|
80
|
+
authToken?: string | null;
|
|
81
|
+
/**
|
|
82
|
+
* Async resolver for a short-lived bearer token. Called once before the
|
|
83
|
+
* engine connects (so the first connection carries a fresh token) and then on
|
|
84
|
+
* a refresh interval ahead of expiry — each result is pushed via
|
|
85
|
+
* `engine.setAuthToken` without tearing down the connection. Wire this to
|
|
86
|
+
* a resolver that mints a short-lived session token (`ek_`/`rk_`) — e.g.
|
|
87
|
+
* `getSyncCapabilityToken` — or your own. Takes precedence over
|
|
88
|
+
* {@link authEndpoint}.
|
|
89
|
+
*/
|
|
90
|
+
getToken?: () => Promise<string | null>;
|
|
91
|
+
/**
|
|
92
|
+
* Liveblocks/Stripe-style auth endpoint: a URL on YOUR backend that returns
|
|
93
|
+
* `{ token }` — the `ek_` ephemeral key your server minted for the logged-in
|
|
94
|
+
* user with `ablo.sessions.create({ user: { id } })`. The provider POSTs to it
|
|
95
|
+
* (with cookies) to fetch + refresh the bearer, so the browser carries no
|
|
96
|
+
* secret. Shorthand for a {@link getToken} that does the fetch; ignored when
|
|
97
|
+
* `getToken` is set.
|
|
98
|
+
*/
|
|
99
|
+
authEndpoint?: string;
|
|
75
100
|
/** Optional Zero-style custom mutators. */
|
|
76
101
|
mutators?: MutatorDefs<Schema<R>>;
|
|
77
102
|
/** Options forwarded to the internal `useMutators` call (e.g., `undoScope`). */
|
|
@@ -114,7 +139,19 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
|
|
|
114
139
|
sessionErrorDetector?: SessionErrorDetector;
|
|
115
140
|
onlineStatus?: OnlineStatusProvider;
|
|
116
141
|
configOverrides?: SyncEngineConfig;
|
|
142
|
+
/**
|
|
143
|
+
* Raw sync-group strings for the initial connection. Prefer {@link scope} —
|
|
144
|
+
* the model form (`{ decks: deckId }`) that the engine resolves through the
|
|
145
|
+
* schema's `scope`, so you never hand-write a `deck:<id>` string. Both merge.
|
|
146
|
+
*/
|
|
117
147
|
syncGroups?: string[];
|
|
148
|
+
/**
|
|
149
|
+
* Model-form connection scope: `{ decks: deckId, documents: documentId }` or
|
|
150
|
+
* entity refs. Resolved through the schema's per-model `scope` into group
|
|
151
|
+
* strings (so typename `SlideDeck` → `deck:<id>`), unioned with {@link syncGroups}.
|
|
152
|
+
* Memoize the object if it's derived, to avoid rotating the engine each render.
|
|
153
|
+
*/
|
|
154
|
+
scope?: ParticipantScope;
|
|
118
155
|
bootstrapBaseUrl?: string;
|
|
119
156
|
maxPoolSize?: number;
|
|
120
157
|
/**
|
|
@@ -5,7 +5,7 @@ import { Ablo } from '../client/Ablo.js';
|
|
|
5
5
|
import { createParticipantClaimId, parseParticipantTtlSeconds, resolveParticipantSyncGroups, } from '../sync/participants.js';
|
|
6
6
|
import { SyncContext } from './context.js';
|
|
7
7
|
import { AbloInternalContext } from './internalContext.js';
|
|
8
|
-
import { AbloValidationError } from '../errors.js';
|
|
8
|
+
import { AbloValidationError, AbloAuthenticationError } from '../errors.js';
|
|
9
9
|
import { useSyncStatus } from './useSyncStatus.js';
|
|
10
10
|
import { DefaultFallback } from './DefaultFallback.js';
|
|
11
11
|
// ── Implementation ───────────────────────────────────────────────────
|
|
@@ -32,7 +32,7 @@ function createErrorEmitter() {
|
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
export function AbloProvider(props) {
|
|
35
|
-
const { schema, url = 'wss://mesh.ablo.finance', userId, teamIds, apiKey, preventUnsavedChanges, onSessionExpired, onError, observability, logger, mutationExecutor, mutationDispatcher, sessionErrorDetector, onlineStatus, configOverrides, syncGroups, bootstrapBaseUrl, maxPoolSize, persistence, bootstrapMode, fallback = _jsx(DefaultFallback, {}), children, } = props;
|
|
35
|
+
const { schema, url = 'wss://mesh.ablo.finance', userId, teamIds, apiKey, authToken, getToken, authEndpoint, preventUnsavedChanges, onSessionExpired, onError, observability, logger, mutationExecutor, mutationDispatcher, sessionErrorDetector, onlineStatus, configOverrides, syncGroups, scope, bootstrapBaseUrl, maxPoolSize, persistence, bootstrapMode, fallback = _jsx(DefaultFallback, {}), children, } = props;
|
|
36
36
|
// Account scope is no longer accepted from props. The engine learns
|
|
37
37
|
// it from auth (capability token) at bootstrap and we read it back
|
|
38
38
|
// out of `_store.orgId` once `engine.ready()` resolves.
|
|
@@ -54,6 +54,25 @@ export function AbloProvider(props) {
|
|
|
54
54
|
useEffect(() => {
|
|
55
55
|
return errorEmitter.subscribe((err) => onErrorRef.current?.(err));
|
|
56
56
|
}, [errorEmitter]);
|
|
57
|
+
// Stash the token resolver in a ref so a new function identity each render
|
|
58
|
+
// does not re-key (tear down) the engine. Read at fire time in the connect +
|
|
59
|
+
// refresh paths below — same `useEventCallback` idiom as `onError`. `getToken`
|
|
60
|
+
// wins; otherwise `authEndpoint` is fetched for `{ token }`.
|
|
61
|
+
const tokenFromEndpoint = authEndpoint
|
|
62
|
+
? async () => {
|
|
63
|
+
const res = await fetch(authEndpoint, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
credentials: 'include',
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok)
|
|
69
|
+
return null;
|
|
70
|
+
const body = (await res.json());
|
|
71
|
+
return body.token ?? null;
|
|
72
|
+
}
|
|
73
|
+
: undefined;
|
|
74
|
+
const getTokenRef = useRef(getToken ?? tokenFromEndpoint);
|
|
75
|
+
getTokenRef.current = getToken ?? tokenFromEndpoint;
|
|
57
76
|
// ── Engine lifecycle keyed on (userId, url) ─────────────────────
|
|
58
77
|
//
|
|
59
78
|
// The engine rotates when either of these change. For everything
|
|
@@ -79,6 +98,7 @@ export function AbloProvider(props) {
|
|
|
79
98
|
schema,
|
|
80
99
|
...(userId ? { user: { id: userId, teamIds } } : {}),
|
|
81
100
|
apiKey,
|
|
101
|
+
...(authToken ? { authToken } : {}),
|
|
82
102
|
logger,
|
|
83
103
|
observability,
|
|
84
104
|
sessionErrorDetector,
|
|
@@ -86,7 +106,11 @@ export function AbloProvider(props) {
|
|
|
86
106
|
mutationExecutor,
|
|
87
107
|
mutationDispatcher,
|
|
88
108
|
configOverrides,
|
|
89
|
-
|
|
109
|
+
// Union raw strings with model-form `scope` resolved through the schema,
|
|
110
|
+
// so `scope={{ decks: id }}` becomes `deck:<id>` via the model's `scope`.
|
|
111
|
+
syncGroups: scope
|
|
112
|
+
? [...(syncGroups ?? []), ...resolveParticipantSyncGroups(scope, schema)]
|
|
113
|
+
: syncGroups,
|
|
90
114
|
bootstrapBaseUrl,
|
|
91
115
|
maxPoolSize,
|
|
92
116
|
persistence,
|
|
@@ -117,6 +141,52 @@ export function AbloProvider(props) {
|
|
|
117
141
|
// saves once `useAblo` exists.
|
|
118
142
|
(async () => {
|
|
119
143
|
try {
|
|
144
|
+
// Resolve a fresh bearer token BEFORE `ready()` (which connects —
|
|
145
|
+
// the engine is built with `autoStart: false`), so the first WS
|
|
146
|
+
// upgrade + bootstrap carry it. No race: nothing has connected yet.
|
|
147
|
+
const fetchToken = getTokenRef.current;
|
|
148
|
+
if (fetchToken) {
|
|
149
|
+
const token = await fetchToken();
|
|
150
|
+
if (isStale || abort.signal.aborted)
|
|
151
|
+
return;
|
|
152
|
+
if (token) {
|
|
153
|
+
engine.setAuthToken(token);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// A configured `getToken` that resolves to falsy means "no active
|
|
157
|
+
// session" — the session-token resolver (e.g. `getSyncCapabilityToken`)
|
|
158
|
+
// returns null when logged out, the session hasn't hydrated, or the
|
|
159
|
+
// mint endpoint rejects. Its contract is "the provider then surfaces
|
|
160
|
+
// the usual unauthenticated path rather than connecting with a bad token."
|
|
161
|
+
//
|
|
162
|
+
// We MUST short-circuit here rather than fall through to
|
|
163
|
+
// `engine.ready()`. On apps/web's identity path (org + user.id are
|
|
164
|
+
// both supplied) `resolveParticipantIdentity` takes the
|
|
165
|
+
// trust-the-caller Branch 3, which does NOT validate the (absent)
|
|
166
|
+
// token — so init would proceed into `store.initialize()` and open
|
|
167
|
+
// IndexedDB *before* any auth-bearing bootstrap/WS call. If storage
|
|
168
|
+
// is at all wedged the no-session condition then surfaces only as an
|
|
169
|
+
// opaque `IndexedDB "ablo_databases" open did not resolve within
|
|
170
|
+
// 10000ms` stall, never the real auth error. Surface `session_expired`
|
|
171
|
+
// explicitly and let the app redirect to sign-in.
|
|
172
|
+
//
|
|
173
|
+
// Unlike the server-detected `onSessionError` handler above, we do
|
|
174
|
+
// NOT purge: nothing connected or wrote this mount, so any cached
|
|
175
|
+
// IndexedDB belongs to a prior session and is reconciled by the next
|
|
176
|
+
// valid bootstrap — purging here would drop a still-valid offline
|
|
177
|
+
// queue on a merely transient null token.
|
|
178
|
+
const authErr = new AbloAuthenticationError('No session token available — getToken() resolved to null. The ' +
|
|
179
|
+
'session is missing or expired; sign in again.', { code: 'session_expired' });
|
|
180
|
+
errorEmitter.emit(authErr);
|
|
181
|
+
try {
|
|
182
|
+
await onSessionExpired?.();
|
|
183
|
+
}
|
|
184
|
+
catch (hookErr) {
|
|
185
|
+
errorEmitter.emit(hookErr);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
120
190
|
await engine.ready();
|
|
121
191
|
if (isStale || abort.signal.aborted)
|
|
122
192
|
return;
|
|
@@ -142,6 +212,35 @@ export function AbloProvider(props) {
|
|
|
142
212
|
// `mutationExecutor` identity change would destroy the WebSocket.
|
|
143
213
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
144
214
|
}, [engineKey]);
|
|
215
|
+
// ── Bearer-token refresh ─────────────────────────────────────────
|
|
216
|
+
//
|
|
217
|
+
// Short-lived tokens (the sync-server JWT defaults to 15m) must be rolled
|
|
218
|
+
// before they expire or the next reconnect/HTTP call fails auth. Re-resolve
|
|
219
|
+
// ahead of expiry and push via `engine.setAuthToken`, which swaps the token
|
|
220
|
+
// on the live WebSocket + bootstrap header without tearing down the engine.
|
|
221
|
+
// Only runs when a `getToken` resolver is wired (cookie deployments skip it).
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const engine = engineState.engine;
|
|
224
|
+
if (!engine || !getTokenRef.current)
|
|
225
|
+
return;
|
|
226
|
+
// Comfortably inside the 15m JWT lifetime; a missed tick is recovered by
|
|
227
|
+
// the next one well before expiry.
|
|
228
|
+
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
|
|
229
|
+
const id = setInterval(() => {
|
|
230
|
+
void (async () => {
|
|
231
|
+
try {
|
|
232
|
+
const token = await getTokenRef.current?.();
|
|
233
|
+
if (token)
|
|
234
|
+
engine.setAuthToken(token);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// Transient (offline / session check). The next tick retries; the
|
|
238
|
+
// engine keeps using the still-valid current token until then.
|
|
239
|
+
}
|
|
240
|
+
})();
|
|
241
|
+
}, REFRESH_INTERVAL_MS);
|
|
242
|
+
return () => clearInterval(id);
|
|
243
|
+
}, [engineState.engine]);
|
|
145
244
|
// ── beforeunload + preventUnsavedChanges ─────────────────────────
|
|
146
245
|
useEffect(() => {
|
|
147
246
|
if (typeof window === 'undefined')
|
|
@@ -251,7 +350,11 @@ export function useParticipant(opts) {
|
|
|
251
350
|
const ctx = useContext(AbloInternalContext);
|
|
252
351
|
const engine = ctx?.engine ?? null;
|
|
253
352
|
const { paused = false } = opts;
|
|
254
|
-
|
|
353
|
+
// Resolve the model-form scope ({ decks: id } / refs) THROUGH the schema, so a
|
|
354
|
+
// model's declared `scope` kind is honored (typename `SlideDeck` → `deck:<id>`,
|
|
355
|
+
// not the `type:id` string fallback). Schema appears once the engine is ready;
|
|
356
|
+
// until then refs resolve by convention, then re-resolve when it arrives.
|
|
357
|
+
const scopeKey = JSON.stringify(resolveParticipantSyncGroups(opts.scope, engine?.schema).sort());
|
|
255
358
|
const scopedSyncGroups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
|
|
256
359
|
const [claimError, setClaimError] = useState(null);
|
|
257
360
|
const [claimConnected, setClaimConnected] = useState(false);
|
|
@@ -33,4 +33,4 @@ export interface ClientSideSuspenseProps {
|
|
|
33
33
|
/** What to render once the subtree is cleared to render. */
|
|
34
34
|
children: ReactNode;
|
|
35
35
|
}
|
|
36
|
-
export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react
|
|
36
|
+
export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react").JSX.Element;
|
|
@@ -21,4 +21,4 @@
|
|
|
21
21
|
* pass `fallback={null}`. Consumers who want to skip the gate entirely
|
|
22
22
|
* pass `fallback="passthrough"`.
|
|
23
23
|
*/
|
|
24
|
-
export declare function DefaultFallback(): import("react
|
|
24
|
+
export declare function DefaultFallback(): import("react").JSX.Element;
|
|
@@ -4,7 +4,7 @@ export interface SyncGroupProviderProps {
|
|
|
4
4
|
id: string;
|
|
5
5
|
children: ReactNode;
|
|
6
6
|
}
|
|
7
|
-
export declare function SyncGroupProvider({ id, children }: SyncGroupProviderProps): import("react
|
|
7
|
+
export declare function SyncGroupProvider({ id, children }: SyncGroupProviderProps): import("react").JSX.Element;
|
|
8
8
|
/**
|
|
9
9
|
* Returns the ID of the nearest `<SyncGroupProvider>`. Throws if
|
|
10
10
|
* called outside one — sync-group awareness is mandatory by design,
|
package/dist/react/index.d.ts
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* immediately. The provider-level `fallback` is the default path.
|
|
14
14
|
*
|
|
15
15
|
* Data hooks:
|
|
16
|
-
* useAblo((ablo) => ablo.tasks.
|
|
16
|
+
* useAblo((ablo) => ablo.tasks.get(id)) — primary React read API (sync local snapshot)
|
|
17
17
|
* useAblo() — typed client for callbacks/effects
|
|
18
|
-
* (reads: ablo.<model>.
|
|
18
|
+
* (sync local reads: ablo.<model>.get/getAll;
|
|
19
|
+
* async server reads: ablo.<model>.retrieve/list;
|
|
19
20
|
* writes: ablo.<model>.create/update/delete)
|
|
20
21
|
* useMutators(defs, opts?) — Zero-style custom mutators
|
|
21
22
|
* useUndoScope(name) — per-surface undo/redo
|
package/dist/react/index.js
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* immediately. The provider-level `fallback` is the default path.
|
|
14
14
|
*
|
|
15
15
|
* Data hooks:
|
|
16
|
-
* useAblo((ablo) => ablo.tasks.
|
|
16
|
+
* useAblo((ablo) => ablo.tasks.get(id)) — primary React read API (sync local snapshot)
|
|
17
17
|
* useAblo() — typed client for callbacks/effects
|
|
18
|
-
* (reads: ablo.<model>.
|
|
18
|
+
* (sync local reads: ablo.<model>.get/getAll;
|
|
19
|
+
* async server reads: ablo.<model>.retrieve/list;
|
|
19
20
|
* writes: ablo.<model>.create/update/delete)
|
|
20
21
|
* useMutators(defs, opts?) — Zero-style custom mutators
|
|
21
22
|
* useUndoScope(name) — per-surface undo/redo
|
package/dist/react/useAblo.d.ts
CHANGED
|
@@ -45,11 +45,11 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
|
|
|
45
45
|
* // With Register augmentation (recommended):
|
|
46
46
|
* const ablo = useAblo();
|
|
47
47
|
* if (!ablo) return <Loading />;
|
|
48
|
-
* const
|
|
48
|
+
* const doc = await ablo.documents.retrieve(id); // async server read
|
|
49
49
|
*
|
|
50
|
-
* // Reactive selector:
|
|
51
|
-
* const doc = useAblo((ablo) => ablo.documents.
|
|
52
|
-
* const active = useAblo((ablo) => ablo.documents.
|
|
50
|
+
* // Reactive selector (sync local-graph snapshot):
|
|
51
|
+
* const doc = useAblo((ablo) => ablo.documents.get(id)) ?? serverDoc;
|
|
52
|
+
* const active = useAblo((ablo) => ablo.documents.claim.state(id));
|
|
53
53
|
*
|
|
54
54
|
* // Without augmentation, pass the schema generic:
|
|
55
55
|
* const ablo = useAblo<(typeof schema)['models']>();
|
package/dist/react/useAblo.js
CHANGED
|
@@ -9,7 +9,7 @@ function readModelResult(engine, modelClient, id, initial) {
|
|
|
9
9
|
if (!modelClient || id === undefined) {
|
|
10
10
|
return { data: initial, claims: EMPTY_CLAIMS, claimed: false };
|
|
11
11
|
}
|
|
12
|
-
const data = snapshotValue(modelClient.
|
|
12
|
+
const data = snapshotValue(modelClient.get(id) ?? initial);
|
|
13
13
|
const meta = getModelClientMeta(modelClient);
|
|
14
14
|
const claims = meta && engine
|
|
15
15
|
? engine.intents.list({ model: meta.key, id })
|
|
@@ -29,7 +29,6 @@ export function useAblo(modelOrSelect, id, options) {
|
|
|
29
29
|
const ctx = useContext(AbloInternalContext);
|
|
30
30
|
const engine = ctx?.engine ?? null;
|
|
31
31
|
const initial = options?.initial;
|
|
32
|
-
const hasSelection = modelOrSelect !== undefined;
|
|
33
32
|
const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
|
|
34
33
|
const modelClient = typeof modelOrSelect === 'function' && id !== undefined
|
|
35
34
|
? engine
|
|
@@ -38,14 +37,20 @@ export function useAblo(modelOrSelect, id, options) {
|
|
|
38
37
|
: typeof modelOrSelect === 'function'
|
|
39
38
|
? undefined
|
|
40
39
|
: modelOrSelect;
|
|
40
|
+
// Claims live on a non-MobX event emitter (engine.intents), so the useReactive
|
|
41
|
+
// reactions below cannot track them — we bridge changes through a setState bump.
|
|
42
|
+
// ONLY the model-row form (`id !== undefined`) actually reads claims, so gate the
|
|
43
|
+
// subscription on `id`. The selector-only form (`useAblo((a) => a.x.get/getAll)`)
|
|
44
|
+
// never reads claims; subscribing it to the workspace-global intent stream would
|
|
45
|
+
// re-render + double-compute it on every intent/presence delta anywhere (a real
|
|
46
|
+
// storm during AI editing / live collaboration) for a value that can't change.
|
|
41
47
|
const [claimVersion, setClaimVersion] = useState(0);
|
|
42
48
|
useEffect(() => {
|
|
43
|
-
if (!engine ||
|
|
49
|
+
if (!engine || id === undefined)
|
|
44
50
|
return;
|
|
45
51
|
return engine.intents.onChange(() => setClaimVersion((version) => version + 1));
|
|
46
|
-
}, [engine,
|
|
52
|
+
}, [engine, id]);
|
|
47
53
|
const selected = useReactive(() => {
|
|
48
|
-
void claimVersion;
|
|
49
54
|
if (!engine || !isSelectorOnly || typeof modelOrSelect !== 'function') {
|
|
50
55
|
return undefined;
|
|
51
56
|
}
|
|
@@ -66,16 +66,29 @@ export function useReactive(compute, equals = defaultEquals) {
|
|
|
66
66
|
const subscribeVersionRef = useRef(0);
|
|
67
67
|
if (snapshotRef.current === null) {
|
|
68
68
|
snapshotRef.current = { value: compute() };
|
|
69
|
-
computeRef.current = compute;
|
|
70
69
|
}
|
|
71
70
|
else if (computeRef.current !== compute) {
|
|
71
|
+
// `compute` is a fresh inline arrow at virtually every call site, so this
|
|
72
|
+
// branch runs on essentially every render. Reconcile the snapshot against
|
|
73
|
+
// the latest closure, but only force a re-subscription when the value
|
|
74
|
+
// ACTUALLY changed. For the dominant case (same observable source, new
|
|
75
|
+
// arrow identity, unchanged value) this avoids tearing down + recreating
|
|
76
|
+
// the MobX reaction — and its double-compute — on every render. A genuine
|
|
77
|
+
// source swap (a memoized compute closing over a new observable source)
|
|
78
|
+
// changes the value, which both updates the snapshot and bumps
|
|
79
|
+
// `subscribeVersion` so the reaction below re-subscribes and re-tracks the
|
|
80
|
+
// new source's observables.
|
|
72
81
|
const next = compute();
|
|
73
82
|
if (!equals(snapshotRef.current.value, next)) {
|
|
74
83
|
snapshotRef.current = { value: next };
|
|
84
|
+
subscribeVersionRef.current++;
|
|
75
85
|
}
|
|
76
|
-
computeRef.current = compute;
|
|
77
|
-
subscribeVersionRef.current++;
|
|
78
86
|
}
|
|
87
|
+
// Point the long-lived reaction at the latest closure every render. The
|
|
88
|
+
// reaction expression reads `computeRef.current` at fire time, so it always
|
|
89
|
+
// runs the newest compute (and re-tracks its observables) even when we did
|
|
90
|
+
// not re-subscribe above.
|
|
91
|
+
computeRef.current = compute;
|
|
79
92
|
const subscribeVersion = subscribeVersionRef.current;
|
|
80
93
|
const subscribe = useCallback((onChange) => {
|
|
81
94
|
return reaction(() => computeRef.current(), (next) => {
|