@abloatai/ablo 0.5.0 → 0.6.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +242 -135
  3. package/dist/BaseSyncedStore.d.ts +2 -2
  4. package/dist/BaseSyncedStore.js +2 -2
  5. package/dist/api/index.d.ts +3 -3
  6. package/dist/api/index.js +1 -1
  7. package/dist/client/Ablo.d.ts +90 -93
  8. package/dist/client/Ablo.js +121 -60
  9. package/dist/client/ApiClient.d.ts +14 -14
  10. package/dist/client/ApiClient.js +81 -55
  11. package/dist/client/createInternalComponents.d.ts +2 -3
  12. package/dist/client/createInternalComponents.js +2 -3
  13. package/dist/client/createModelProxy.d.ts +90 -87
  14. package/dist/client/createModelProxy.js +124 -127
  15. package/dist/client/index.d.ts +6 -7
  16. package/dist/client/index.js +4 -5
  17. package/dist/client/validateAbloOptions.js +3 -3
  18. package/dist/core/index.d.ts +2 -0
  19. package/dist/core/index.js +7 -0
  20. package/dist/errors.d.ts +8 -8
  21. package/dist/errors.js +18 -10
  22. package/dist/index.d.ts +9 -8
  23. package/dist/index.js +7 -11
  24. package/dist/interfaces/index.d.ts +2 -10
  25. package/dist/mutators/Transaction.d.ts +2 -2
  26. package/dist/mutators/Transaction.js +2 -2
  27. package/dist/mutators/mutateActions.d.ts +44 -0
  28. package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
  29. package/dist/mutators/readerActions.d.ts +32 -0
  30. package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
  31. package/dist/query/types.d.ts +1 -1
  32. package/dist/react/AbloProvider.d.ts +1 -1
  33. package/dist/react/AbloProvider.js +3 -3
  34. package/dist/react/context.d.ts +4 -4
  35. package/dist/react/index.d.ts +4 -5
  36. package/dist/react/index.js +3 -7
  37. package/dist/react/useAblo.d.ts +14 -14
  38. package/dist/react/useAblo.js +26 -26
  39. package/dist/react/useIntent.d.ts +2 -2
  40. package/dist/react/useIntent.js +2 -2
  41. package/dist/react/useMutators.d.ts +1 -1
  42. package/dist/react/usePresence.d.ts +3 -3
  43. package/dist/react/usePresence.js +4 -4
  44. package/dist/react/useUndoScope.d.ts +1 -1
  45. package/dist/schema/diff.d.ts +161 -0
  46. package/dist/schema/diff.js +262 -0
  47. package/dist/schema/generate.d.ts +19 -0
  48. package/dist/schema/generate.js +87 -0
  49. package/dist/schema/index.d.ts +4 -1
  50. package/dist/schema/index.js +7 -1
  51. package/dist/schema/schema.d.ts +83 -32
  52. package/dist/schema/schema.js +58 -12
  53. package/dist/schema/serialize.d.ts +92 -0
  54. package/dist/schema/serialize.js +227 -0
  55. package/dist/sync/SyncWebSocket.d.ts +17 -0
  56. package/dist/sync/SyncWebSocket.js +46 -1
  57. package/dist/sync/awaitIntentGrant.d.ts +26 -0
  58. package/dist/sync/awaitIntentGrant.js +60 -0
  59. package/dist/sync/createIntentStream.js +43 -4
  60. package/dist/sync/createPresenceStream.js +1 -1
  61. package/dist/sync/participants.d.ts +2 -2
  62. package/dist/sync/participants.js +4 -4
  63. package/dist/types/global.d.ts +43 -52
  64. package/dist/types/global.js +16 -18
  65. package/dist/types/streams.d.ts +37 -9
  66. package/docs/api.md +68 -158
  67. package/docs/audit.md +5 -5
  68. package/docs/client-behavior.md +41 -42
  69. package/docs/coordination.md +294 -0
  70. package/docs/data-sources.md +14 -14
  71. package/docs/examples/agent-human.md +30 -32
  72. package/docs/examples/ai-sdk-tool.md +32 -33
  73. package/docs/examples/existing-python-backend.md +35 -33
  74. package/docs/examples/nextjs.md +24 -25
  75. package/docs/examples/server-agent.md +20 -61
  76. package/docs/guarantees.md +30 -55
  77. package/docs/identity.md +458 -0
  78. package/docs/index.md +12 -24
  79. package/docs/integration-guide.md +106 -116
  80. package/docs/interaction-model.md +29 -95
  81. package/docs/mcp/claude-code.md +3 -3
  82. package/docs/mcp/cursor.md +1 -1
  83. package/docs/mcp/windsurf.md +1 -1
  84. package/docs/mcp.md +11 -26
  85. package/docs/quickstart.md +43 -49
  86. package/docs/react.md +73 -23
  87. package/docs/roadmap.md +5 -7
  88. package/llms.txt +34 -39
  89. package/package.json +1 -1
  90. package/dist/react/useMutate.d.ts +0 -83
  91. package/dist/react/useQuery.d.ts +0 -123
  92. package/dist/react/useQuery.js +0 -145
  93. package/dist/react/useReader.d.ts +0 -69
  94. package/docs/capabilities.md +0 -163
@@ -1,123 +0,0 @@
1
- import type { ModelScope } from '../types/index.js';
2
- import type { Schema } from '../schema/schema.js';
3
- import type { InferModel } from '../schema/schema.js';
4
- import type { ResolveSchema } from '../types/global.js';
5
- /** Narrow model-key union for the zero-arg overload. */
6
- type GlobalModelKey = ResolveSchema extends {
7
- models: infer M;
8
- } ? keyof M & string : string;
9
- /** Typed entity shape for a given model key. Falls back to a loose shape
10
- * when the resolved schema doesn't extend the full `Schema` contract
11
- * (i.e., no global augmentation present). */
12
- type GlobalEntity<K extends string> = ResolveSchema extends Schema ? K extends keyof ResolveSchema['models'] ? InferModel<ResolveSchema, K> : Record<string, unknown> : Record<string, unknown>;
13
- /**
14
- * Compatibility query hook for entity collections.
15
- *
16
- * Prefer selector reads for new integrations:
17
- *
18
- * ```ts
19
- * const tasks = useAblo((ablo) =>
20
- * ablo.tasks.list({ where: { status: 'todo' } }),
21
- * );
22
- * ```
23
- *
24
- * This hook remains for older string-keyed integrations.
25
- *
26
- * **Typed overload:**
27
- * ```ts
28
- * import { schema } from '@/sync/schema';
29
- * const chats = useQuery(schema, 'chats', { where: { userId } });
30
- * // chats is fully typed: Chat[] with displayTitle, icon, color, etc.
31
- * ```
32
- *
33
- * **Untyped overload (legacy):**
34
- * ```ts
35
- * const chats = useQuery('Chat');
36
- * // chats is Record<string, unknown>[]
37
- * ```
38
- */
39
- export interface QueryOptions<T = Record<string, unknown>> {
40
- /** Declarative field-level filter. Shallow match: all specified fields must match. */
41
- where?: Partial<T>;
42
- /** Arbitrary predicate function for complex logic. Applied AFTER where. */
43
- filter?: (entity: T) => boolean;
44
- /** Sort field name. */
45
- orderBy?: keyof T & string;
46
- /** Sort direction. Default: 'asc'. */
47
- order?: 'asc' | 'desc';
48
- /** Max results. */
49
- limit?: number;
50
- /** Skip N results (pagination). */
51
- offset?: number;
52
- /** Filter by model scope (live, archived, all). Default: live. */
53
- scope?: ModelScope;
54
- }
55
- /**
56
- * Typed query (explicit schema arg).
57
- *
58
- * @deprecated Prefer `useAblo((ablo) => ablo.<model>.list(options))` for new
59
- * integrations. This overload remains for compatibility with older
60
- * string-keyed React code.
61
- *
62
- * ```ts
63
- * const tasks = useQuery(schema, 'tasks', { where: { status: 'todo' } });
64
- * // tasks: Task[] — fully typed from Zod shape + computed getters
65
- * ```
66
- */
67
- export declare function useQuery<S extends Schema, K extends keyof S['models'] & string>(schema: S, modelKey: K, options?: QueryOptions<InferModel<S, K>>): InferModel<S, K>[];
68
- /**
69
- * Typed query (global-augmented): pass just the model key. Resolves
70
- * the schema from the `AbloSync` global augmentation the consumer
71
- * declared in a `.d.ts`. No `schema` arg at the call site — this is
72
- * the Liveblocks-style ergonomic path.
73
- *
74
- * @deprecated Prefer `useAblo((ablo) => ablo.<model>.list(options))` for new
75
- * integrations. This overload remains for compatibility with older
76
- * string-keyed React code.
77
- *
78
- * ```ts
79
- * // apps/your-app/src/ablo-sync.d.ts
80
- * declare global { interface AbloSync { Schema: typeof schema } }
81
- *
82
- * // any component
83
- * const tasks = useQuery('tasks', { where: { status: 'todo' } });
84
- * // tasks: Task[] — typed via the declared global
85
- * ```
86
- *
87
- * When no global augmentation exists, `GlobalEntity` falls back to
88
- * `Record<string, unknown>` — same ergonomics as the legacy untyped
89
- * overload, with the key still validated against the resolved schema's
90
- * model keys when that schema is declared.
91
- */
92
- export declare function useQuery<K extends GlobalModelKey>(modelKey: K, options?: QueryOptions<GlobalEntity<K>>): GlobalEntity<K>[];
93
- /** @deprecated Prefer selector reads through `useAblo`. */
94
- export declare function useQuery<T = Record<string, unknown>>(typename: string, options?: QueryOptions<T>): T[];
95
- /**
96
- * Compatibility single-entity lookup. Prefer selector reads:
97
- *
98
- * ```ts
99
- * const task = useAblo((ablo) => ablo.tasks.retrieve(taskId));
100
- * ```
101
- *
102
- * ```ts
103
- * // Typed
104
- * const task = useOne(schema, 'tasks', taskId);
105
- *
106
- * // Untyped (legacy)
107
- * const task = useOne(taskId);
108
- * ```
109
- */
110
- /** @deprecated Prefer `useAblo((ablo) => ablo.<model>.retrieve(id))`. */
111
- export declare function useOne<S extends Schema, K extends keyof S['models'] & string>(schema: S, modelKey: K, id?: string): InferModel<S, K> | undefined;
112
- /** Typed single-entity lookup via the `AbloSync` global augmentation.
113
- *
114
- * @deprecated Prefer `useAblo((ablo) => ablo.<model>.retrieve(id))`.
115
- *
116
- * The pool `.get(id)` call doesn't actually need the typename at runtime
117
- * — the return is already keyed by id globally — so the model key serves
118
- * as a compile-time narrowing hint for consumers who want the specific
119
- * entity type at the call site. */
120
- export declare function useOne<K extends GlobalModelKey>(modelKey: K, id?: string): GlobalEntity<K> | undefined;
121
- /** @deprecated Prefer `useAblo((ablo) => ablo.<model>.retrieve(id))`. */
122
- export declare function useOne<T = Record<string, unknown>>(id?: string): T | undefined;
123
- export {};
@@ -1,145 +0,0 @@
1
- 'use client';
2
- import { useMemo, useEffect, useRef, useCallback } from 'react';
3
- import { useSyncContext } from './context.js';
4
- import { useReactive } from './useReactive.js';
5
- // ── Stable key helper ───────────────────────────────────────────────
6
- /**
7
- * Produce a stable string key for QueryOptions so useMemo only recreates
8
- * the view when the logical query changes.
9
- *
10
- * - `where` is serialized via JSON.stringify (deterministic for simple values).
11
- * - `filter` is a function — we track its reference identity.
12
- * - Primitives (orderBy, order, limit, offset, scope) are included directly.
13
- */
14
- function useStableKey(options) {
15
- // We use a ref to track the previous filter reference. When it changes
16
- // the key changes and the view is recreated.
17
- const filterRef = useRef(undefined);
18
- // Bump a generation counter when the filter reference changes
19
- const genRef = useRef(0);
20
- if (options?.filter !== filterRef.current) {
21
- filterRef.current = options?.filter;
22
- genRef.current++;
23
- }
24
- return useMemo(() => {
25
- if (!options)
26
- return '';
27
- const parts = [];
28
- if (options.where)
29
- parts.push('w:' + JSON.stringify(options.where));
30
- if (options.filter)
31
- parts.push('f:' + genRef.current);
32
- if (options.orderBy)
33
- parts.push('ob:' + String(options.orderBy));
34
- if (options.order)
35
- parts.push('o:' + options.order);
36
- if (options.limit !== undefined)
37
- parts.push('l:' + options.limit);
38
- if (options.offset !== undefined)
39
- parts.push('off:' + options.offset);
40
- if (options.scope)
41
- parts.push('s:' + options.scope);
42
- return parts.join('|');
43
- // eslint-disable-next-line react-hooks/exhaustive-deps
44
- }, [
45
- // eslint-disable-next-line react-hooks/exhaustive-deps
46
- options?.where ? JSON.stringify(options.where) : '',
47
- genRef.current,
48
- options?.orderBy,
49
- options?.order,
50
- options?.limit,
51
- options?.offset,
52
- options?.scope,
53
- ]);
54
- }
55
- // ── Implementation ──────────────────────────────────────────────────
56
- export function useQuery(schemaOrTypename, modelKeyOrOptions, maybeOptions) {
57
- const ctx = useSyncContext();
58
- const { store } = ctx;
59
- let typename;
60
- let options;
61
- if (typeof schemaOrTypename === 'string') {
62
- // First arg is a string. Could be either the new zero-arg typed
63
- // overload (a schema model key, resolved via `ctx.schema`) or the
64
- // legacy untyped overload (a raw typename like 'Chat'). When a
65
- // schema is present on the context and the string maps to a known
66
- // model key, we look up the real typename from the schema's
67
- // `ModelDef.typename`. Otherwise we treat the string as a typename
68
- // directly — preserving the legacy behavior for any non-opting
69
- // consumer. Both paths converge on the same runtime lookup.
70
- const key = schemaOrTypename;
71
- const ctxSchema = ctx.schema;
72
- const modelDef = ctxSchema
73
- ? ctxSchema.models[key]
74
- : undefined;
75
- typename = modelDef?.typename ?? key;
76
- options = modelKeyOrOptions;
77
- }
78
- else {
79
- // Explicit schema path: useQuery(schema, 'chats', options?)
80
- const schema = schemaOrTypename;
81
- const modelKey = modelKeyOrOptions;
82
- const modelDef = schema.models[modelKey];
83
- typename = modelDef?.typename ?? modelKey;
84
- options = maybeOptions;
85
- }
86
- const optionsKey = useStableKey(options);
87
- // The QueryView is generic-erased to `Record<string, unknown>`, but
88
- // the caller's filter is typed in `T`. Wrap rather than cast: the
89
- // view passes a Record at runtime and the wrapper narrows to T —
90
- // single typed boundary, no `as unknown as` chain.
91
- const userFilter = options?.filter;
92
- const viewOptions = options
93
- ? {
94
- where: options.where,
95
- filter: userFilter
96
- ? (entity) => userFilter(entity)
97
- : undefined,
98
- orderBy: options.orderBy,
99
- order: options.order,
100
- limit: options.limit,
101
- offset: options.offset,
102
- scope: options.scope,
103
- }
104
- : undefined;
105
- const view = useMemo(() => store.pool.createView(typename, viewOptions),
106
- // eslint-disable-next-line react-hooks/exhaustive-deps
107
- [store.pool, typename, optionsKey]);
108
- useEffect(() => () => view.dispose(), [view]);
109
- // Self-subscribing — consumers never wrap their component in
110
- // `observer`. `useReactive` tracks the observables read inside the
111
- // compute function (`view.results`), recomputes on change, and
112
- // returns a stable slice so downstream `.sort()` / `.reverse()`
113
- // calls don't trip MobX error 37. The default structural equality
114
- // check prevents re-renders when nothing actually moved.
115
- //
116
- // The compute closure MUST be stable when `view` is stable. Without
117
- // useCallback([view]), each render passes a fresh arrow to
118
- // useReactive, which then can't distinguish "swapped to a new
119
- // QueryView" from "same view, new render" — the wrong call would
120
- // either re-subscribe every render (waste) or never re-subscribe
121
- // when view actually swaps (stale snapshot bug returning a previous
122
- // view's results forever).
123
- const compute = useCallback(() => view.results.slice(), [view]);
124
- return useReactive(compute);
125
- }
126
- export function useOne(schemaOrIdOrKey, modelKeyOrId, maybeId) {
127
- const { store } = useSyncContext();
128
- if (schemaOrIdOrKey === undefined) {
129
- return undefined;
130
- }
131
- if (typeof schemaOrIdOrKey === 'string') {
132
- // Either `useOne(id)` (legacy, one arg) or `useOne(modelKey, id)` (global).
133
- // Disambiguate by whether a second arg was passed — both paths
134
- // converge on the same runtime pool lookup because entity IDs are
135
- // globally unique across model types.
136
- if (modelKeyOrId !== undefined) {
137
- return store.pool.get(modelKeyOrId);
138
- }
139
- return store.pool.get(schemaOrIdOrKey);
140
- }
141
- // Explicit schema path: useOne(schema, 'tasks', id)
142
- if (!maybeId)
143
- return undefined;
144
- return store.pool.get(maybeId);
145
- }
@@ -1,69 +0,0 @@
1
- import type { Schema, InferModel } from '../schema/schema.js';
2
- import type { ResolveSchema } from '../types/global.js';
3
- import type { SyncStoreContract } from './context.js';
4
- type GlobalReaderKey = ResolveSchema extends {
5
- models: infer M;
6
- } ? keyof M & string : string;
7
- type GlobalReaderActions<K extends string> = ResolveSchema extends Schema ? K extends keyof ResolveSchema['models'] & string ? ReaderActions<ResolveSchema, K> : ReaderActions<Schema, string> : ReaderActions<Schema, string>;
8
- /**
9
- * Compatibility schema-typed imperative reader. Returns functions for one-off lookups
10
- * without subscribing the component to collection changes.
11
- *
12
- * Prefer `useAblo()` and call `ablo.<model>.retrieve/list` inside callbacks and
13
- * effects in new integrations. This hook remains for older string-keyed code.
14
- *
15
- * Use this inside event handlers, mutation callbacks, or effects where you
16
- * need a current snapshot of the pool but don't want to trigger re-renders
17
- * on every entity change.
18
- *
19
- * For reactive reads, use selector reads through
20
- * `useAblo((ablo) => ablo.<model>.retrieve(id))`.
21
- *
22
- * @example
23
- * import { schema } from '@ablo/schema';
24
- * import { useReader } from '@abloatai/ablo/react';
25
- *
26
- * function useTaskMutations() {
27
- * const read = useReader(schema, 'tasks');
28
- *
29
- * return {
30
- * create: async (data) => {
31
- * // Imperative read — uses FK index when available (O(1))
32
- * const existing = read.findMany({ where: { projectId: data.projectId } });
33
- * const order = existing.reduce((m, t) => Math.max(m, t.order ?? 0), 0) + 1;
34
- * // ...
35
- * },
36
- * };
37
- * }
38
- */
39
- export interface ReaderFindOptions<T> {
40
- /** Equality filter — uses FK index when the field is registered. */
41
- where?: Partial<T>;
42
- /** Predicate applied AFTER `where` filtering. */
43
- filter?: (entity: T) => boolean;
44
- /** Sort field. */
45
- orderBy?: keyof T & string;
46
- /** Sort direction. Default: 'asc'. */
47
- order?: 'asc' | 'desc';
48
- /** Max results. */
49
- limit?: number;
50
- /** Skip N results. */
51
- offset?: number;
52
- }
53
- export interface ReaderActions<S extends Schema, K extends keyof S['models'] & string> {
54
- /** Get a single entity by id. Returns undefined if not in pool. */
55
- retrieve: (id: string) => InferModel<S, K> | undefined;
56
- /** Read a collection with optional filters. Snapshot — not reactive. */
57
- list: (options?: ReaderFindOptions<InferModel<S, K>>) => InferModel<S, K>[];
58
- /** Count entities matching the options. */
59
- count: (options?: ReaderFindOptions<InferModel<S, K>>) => number;
60
- }
61
- /**
62
- * Pure factory — testable without React. `useReader` wraps this in useMemo.
63
- */
64
- export declare function createReaderActions<S extends Schema, K extends keyof S['models'] & string>(schema: S, modelKey: K, store: SyncStoreContract): ReaderActions<S, K>;
65
- /** @deprecated Prefer `useAblo()` plus `ablo.<model>.retrieve/list` in callbacks/effects. */
66
- export declare function useReader<S extends Schema, K extends keyof S['models'] & string>(schema: S, modelKey: K): ReaderActions<S, K>;
67
- /** @deprecated Prefer `useAblo()` plus `ablo.<model>.retrieve/list` in callbacks/effects. */
68
- export declare function useReader<K extends GlobalReaderKey>(modelKey: K): GlobalReaderActions<K>;
69
- export {};
@@ -1,163 +0,0 @@
1
- # Capabilities
2
-
3
- A capability is scoped credentials for a non-human actor.
4
-
5
- It is not a task and it is not an intent. It is the permission boundary that
6
- answers who may touch which resources.
7
-
8
- Most apps should use `api.agent(...).run(...)`; the SDK creates and revokes the
9
- capability for that run. Create capabilities directly only for custom runtimes,
10
- MCP sessions, or protocol-level integrations.
11
-
12
- ## Why capabilities, not API keys
13
-
14
- Static API keys protect a human-operated workflow with one shared secret —
15
- fine for a server-to-server integration written once and forgotten. They are
16
- the wrong primitive for AI agents:
17
-
18
- - An agent that holds an account-wide key inherits every permission your
19
- human team has. A leaked key burns the whole account; a confused-deputy
20
- bug lets the agent write to resources it had no business touching.
21
- - Per-task work needs per-task attribution. One static key across every
22
- agent invocation makes the audit trail say "the API key did it" — which
23
- tells you nothing about which run, which prompt, or which user delegated
24
- the action.
25
- - Long-lived secrets accumulate blast radius. The longer a credential is
26
- valid, the more places it travels (logs, env files, agent prompts), and
27
- the wider the leak surface.
28
-
29
- Capabilities replace the one-static-key model with **per-run, per-scope,
30
- short-lived** credentials. The 2025-2026 AI-agent auth consensus (the
31
- OAuth 2.1 / MCP spec, GCP short-lived credentials, AWS STS AssumeRole,
32
- HashiCorp Vault leases, Auth0 Token Vault) converged on the same shape:
33
- issue scoped tokens, attach a TTL, verify per-request, support fast
34
- revocation. Capabilities are Ablo's instance of that pattern.
35
-
36
- ## The three-layer security model
37
-
38
- Every commit is authorized by three independent checks. None of them is
39
- sufficient on its own; together they cap the blast radius of every
40
- credible failure mode.
41
-
42
- 1. **Lease (TTL)** — every capability has an expiry encoded in the bearer
43
- token itself. After the lease, the token decodes but every signature
44
- check fails. Caps the damage from a leaked token without requiring a
45
- database lookup on the hot path.
46
- 2. **Signature verification (per request)** — every commit re-verifies
47
- the token's signature and attenuation. Stateless, cheap (microseconds),
48
- detects forged or tampered tokens. The token's `syncGroups` and
49
- `operations` are checked against the commit's actual targets;
50
- `capability_scope_denied` rejects the request before any write lands.
51
- 3. **Revocation** — `DELETE /v1/capabilities/:id` flips the cap's status
52
- server-side; live WebSocket sessions are closed, future requests are
53
- rejected within seconds. Closes the gap between lease refresh cycles
54
- when you need *immediate* cutoff (compromised agent, accidental
55
- over-grant, end-of-trial cleanup).
56
-
57
- The mental model: **lease prevents the slow leak, signature verification
58
- prevents the forged token, revocation prevents the active attacker.**
59
- Removing any one of the three leaves a class of failure uncovered.
60
-
61
- ## Why this shape, in one paragraph each
62
-
63
- **Lease, not "session"** — A session token requires a database round-trip
64
- on every request to check liveness. A lease is encoded in the token and
65
- verified stateless. Vault popularized the term ("lease, renew, revoke");
66
- the mechanic is the same as AWS STS time-bounded credentials and GCP
67
- short-lived service-account creds. Ablo uses the word "lease" because the
68
- bearer holds a *bounded grant*, not just a timer — the same word
69
- `capability_scope_denied` errors reference.
70
-
71
- **Two scope axes (`syncGroups` + `operations`), not one** — `syncGroups`
72
- narrows *which rows* the actor can see; `operations` narrows *which verbs*
73
- the actor can use. Collapsing them into one set forces an explosion
74
- (`tasks.update:org:acme`, `tasks.delete:org:acme`, ...). Keeping them
75
- orthogonal lets a 3-group × 5-op cap stay 3+5 instead of 3×5. Same shape
76
- as IAM policies (`Resource` + `Action`), Stripe Restricted Keys
77
- (`resource_type` + `permission`), and Biscuit caveats.
78
-
79
- **Strings from `identityRoles`, never invented** — A consumer who types
80
- `'org:acme'` literally couples their code to Ablo's identity convention.
81
- Templates declared once on the schema (see integration-guide.md §1) let
82
- the convention live in one place; consumers reference it by template, not
83
- by hand-typing the prefix. Same boundary as Liveblocks' `prepareSession()`
84
- or PowerSync's named streams: server owns the namespace, client picks a
85
- subset by id.
86
-
87
- **`participantKind` cannot be `'user'`** — Capabilities are explicitly
88
- for non-human actors. A capability minted as a user would let any code
89
- path with that bearer impersonate the human; instead, user actions flow
90
- through session auth (cookies / OAuth) so the audit chain says
91
- "alice@example.com did X" — not "a token did X." Stripe makes the same
92
- split between Restricted API Keys (system) and Connect OAuth (user-on-
93
- behalf-of).
94
-
95
- ## What capabilities aren't
96
-
97
- | Not | Why we didn't ship that |
98
- |---|---|
99
- | **A static API key** | One leaked secret = whole-account compromise. No per-run attribution. No automatic expiry. |
100
- | **An OAuth session token** | OAuth's user-delegation model assumes a human in the loop; agents are the actor, not the delegate. The auth flow round-trips don't fit agent runtimes. |
101
- | **An opaque DB session** | Per-request DB lookup is the slow path. Stateless verification (signature + lease) is the fast path; the DB is the revocation list, not the live-check. |
102
- | **A bearer JWT with `exp`** | Conceptually similar, but Biscuit caveats let us *attenuate* a cap further (delegate a narrower sub-scope to a sub-agent) without re-minting. Plain JWTs can't subset themselves. |
103
-
104
- ## Create
105
-
106
- ```ts
107
- import Ablo from '@abloatai/ablo';
108
-
109
- const admin = Ablo({ apiKey: process.env.ABLO_API_KEY });
110
-
111
- const capability = await admin.capabilities.create({
112
- participantKind: 'agent',
113
- participantId: 'agent:task-writer',
114
- // Identity-anchored groups derived from the schema's `identityRoles`
115
- // registration (see integration-guide.md §1). The strings here mirror
116
- // whatever templates the schema declared — `org:{id}` and friends for
117
- // Ablo's stock schema; a third-party schema with `region:{id}` /
118
- // `customer:{id}` roles would pass those instead.
119
- syncGroups: ['org:acme', 'user:agent:task-writer'],
120
- operations: ['tasks.retrieve', 'tasks.update'],
121
- lease: '10m',
122
- });
123
- ```
124
-
125
- Pass `capability.token` into the agent runtime. The agent never sees admin
126
- credentials.
127
-
128
- ```ts
129
- const agent = capability.client();
130
- ```
131
-
132
- ## Inspect
133
-
134
- ```ts
135
- const record = await admin.capabilities.retrieve(capability.id);
136
-
137
- record.status; // active | expired | revoked
138
- record.operations; // ['tasks.retrieve', 'tasks.update']
139
- ```
140
-
141
- Inspection never returns the bearer token. Tokens are returned once at create
142
- time.
143
-
144
- ## Revoke
145
-
146
- ```ts
147
- await admin.capabilities.revoke(capability.id);
148
- ```
149
-
150
- Revocation is forward-only. Already accepted commits stand; future requests with
151
- that token are rejected within seconds.
152
-
153
- ## Scope Grammar
154
-
155
- | Field | Required | Meaning |
156
- |---|---|---|
157
- | `participantKind` | yes | `agent` or `system`. Capabilities cannot impersonate `user`. |
158
- | `participantId` | recommended | Stable actor id, for example `agent:task-writer`. |
159
- | `syncGroups` | yes | Sync groups the actor can touch. Strings come from the schema's `identityRoles` templates or a model's `syncGroupFormat` — never invented by the caller. |
160
- | `operations` | yes | Typed operation names, for example `tasks.update`. |
161
- | `lease` / `leaseSeconds` | recommended | Crash cleanup window for abandoned actors. |
162
- | `label` | no | Human-readable label for dashboards and audit. |
163
- | `userMeta` | no | Customer-attested end-user metadata for B2B2C flows. |