@withloops.ai/contracts 0.0.0-stage → 0.2.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/dist/api.d.ts +148 -0
- package/dist/api.js +95 -0
- package/dist/events.d.ts +102 -0
- package/dist/events.js +55 -0
- package/dist/ids.d.ts +26 -0
- package/dist/ids.js +21 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/manifest.d.ts +550 -0
- package/dist/manifest.js +215 -0
- package/dist/registry.d.ts +368 -0
- package/dist/registry.js +86 -0
- package/dist/scopes.d.ts +46 -0
- package/dist/scopes.js +40 -0
- package/examples/acme-checks.manifest.json +24 -0
- package/examples/acme-crm.manifest.json +114 -0
- package/examples/hello.manifest.json +14 -0
- package/package.json +41 -4
- package/src/api.ts +124 -0
- package/src/events.ts +65 -0
- package/src/ids.ts +39 -0
- package/src/index.ts +6 -0
- package/src/manifest.ts +250 -0
- package/src/registry.ts +130 -0
- package/src/scopes.ts +44 -0
- package/README.md +0 -3
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AppId, HttpsUrl, SemVer } from './ids.ts';
|
|
3
|
+
import { EventTypeSchema, scopeForEvent } from './events.ts';
|
|
4
|
+
import { ScopeSchema, expandScopes, type Scope } from './scopes.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* App manifest v1 (`loops.manifest.json`). Everything is a Loops App: one manifest, two optional halves —
|
|
8
|
+
* `sync` (declarative CRM connector, run by the one Loops sync engine) and `ui` (LoopKit). Skill §2 #1–#3, §3.
|
|
9
|
+
* Only ever ADD optional fields to v1; a breaking change is manifest v2 and v1 keeps working.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ── Shared ──────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** Loops record kinds a connector maps into. */
|
|
15
|
+
export const RecordKind = z.enum(['CONTACT', 'COMPANY', 'LEAD', 'DEAL']);
|
|
16
|
+
export type RecordKind = z.infer<typeof RecordKind>;
|
|
17
|
+
|
|
18
|
+
const RECORD_SCOPE: Record<string, { read: Scope; write: Scope }> = {
|
|
19
|
+
CONTACT: { read: 'contacts:read', write: 'contacts:write' },
|
|
20
|
+
COMPANY: { read: 'companies:read', write: 'companies:write' },
|
|
21
|
+
LEAD: { read: 'leads:read', write: 'leads:write' },
|
|
22
|
+
DEAL: { read: 'deals:read', write: 'deals:write' },
|
|
23
|
+
contact: { read: 'contacts:read', write: 'contacts:write' },
|
|
24
|
+
company: { read: 'companies:read', write: 'companies:write' },
|
|
25
|
+
lead: { read: 'leads:read', write: 'leads:write' },
|
|
26
|
+
deal: { read: 'deals:read', write: 'deals:write' },
|
|
27
|
+
loop: { read: 'loops:read', write: 'loops:write' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const PathTemplate = z.string().regex(/^\/[A-Za-z0-9_\-./{}:]*$/, 'A path starting with /, e.g. /crm/v3/objects/contacts');
|
|
31
|
+
|
|
32
|
+
// ── sync: the connector half ────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export const SyncAuth = z.discriminatedUnion('type', [
|
|
35
|
+
z.object({
|
|
36
|
+
type: z.literal('oauth2'),
|
|
37
|
+
authorizeUrl: HttpsUrl,
|
|
38
|
+
tokenUrl: HttpsUrl,
|
|
39
|
+
scopes: z.array(z.string().min(1)).default([]),
|
|
40
|
+
refresh: z.boolean().default(true),
|
|
41
|
+
}),
|
|
42
|
+
z.object({
|
|
43
|
+
type: z.literal('apiKey'),
|
|
44
|
+
header: z.string().min(1).default('Authorization'),
|
|
45
|
+
prefix: z.string().optional(),
|
|
46
|
+
}),
|
|
47
|
+
]);
|
|
48
|
+
export type SyncAuth = z.infer<typeof SyncAuth>;
|
|
49
|
+
|
|
50
|
+
/** Who owns a field. The customer's CRM owns its fields (locked in Loops); Loops owns its own. Owner wins, never last-write-wins. */
|
|
51
|
+
export const FieldOwner = z.enum(['theirs', 'ours']);
|
|
52
|
+
|
|
53
|
+
export const SyncField = z.object({
|
|
54
|
+
theirs: z.string().min(1),
|
|
55
|
+
ours: z.string().min(1),
|
|
56
|
+
owner: FieldOwner,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const SyncObject = z.object({
|
|
60
|
+
theirs: z.string().min(1),
|
|
61
|
+
ours: RecordKind,
|
|
62
|
+
list: z.object({
|
|
63
|
+
path: PathTemplate,
|
|
64
|
+
itemsPath: z.string().min(1).default('results'),
|
|
65
|
+
cursorParam: z.string().optional(),
|
|
66
|
+
nextCursorPath: z.string().optional(),
|
|
67
|
+
updatedSinceParam: z.string().optional(),
|
|
68
|
+
}),
|
|
69
|
+
fields: z.array(SyncField).min(1),
|
|
70
|
+
/** Dedupe keys, in order: e.g. ["email"] for contacts, ["domain"] for companies. External id always matches first. */
|
|
71
|
+
match: z.array(z.string().min(1)).default([]),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const SyncLink = z.object({
|
|
75
|
+
from: RecordKind,
|
|
76
|
+
to: RecordKind,
|
|
77
|
+
theirs: z.string().min(1),
|
|
78
|
+
role: z.string().optional(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const ChangeFeed = z
|
|
82
|
+
.object({
|
|
83
|
+
webhook: z
|
|
84
|
+
.object({
|
|
85
|
+
signature: z.object({ scheme: z.enum(['hmac-sha256', 'hmac-sha1']), header: z.string().min(1) }),
|
|
86
|
+
})
|
|
87
|
+
.optional(),
|
|
88
|
+
polling: z.object({ everyMinutes: z.number().int().min(5).max(1440) }).optional(),
|
|
89
|
+
})
|
|
90
|
+
.refine((f) => f.webhook || f.polling, 'Declare a webhook, polling, or both');
|
|
91
|
+
|
|
92
|
+
/** What Loops writes back to the CRM timeline (Loops owns these). */
|
|
93
|
+
export const WriteBackKind = z.enum(['call', 'transcript', 'meeting', 'note', 'action', 'campaignReply']);
|
|
94
|
+
export const WriteBack = z.partialRecord(
|
|
95
|
+
WriteBackKind,
|
|
96
|
+
z.object({ method: z.enum(['POST', 'PUT', 'PATCH']), path: PathTemplate }),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
export const SyncLimits = z.object({
|
|
100
|
+
requestsPerSecond: z.number().positive().max(1000),
|
|
101
|
+
burst: z.number().int().positive().max(10000).optional(),
|
|
102
|
+
retryAfterHeader: z.string().default('Retry-After'),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const SyncManifest = z.object({
|
|
106
|
+
baseUrl: HttpsUrl,
|
|
107
|
+
auth: SyncAuth,
|
|
108
|
+
objects: z.array(SyncObject).min(1),
|
|
109
|
+
links: z.array(SyncLink).default([]),
|
|
110
|
+
changeFeed: ChangeFeed,
|
|
111
|
+
writeBack: WriteBack.default({}),
|
|
112
|
+
limits: SyncLimits,
|
|
113
|
+
});
|
|
114
|
+
export type SyncManifest = z.infer<typeof SyncManifest>;
|
|
115
|
+
|
|
116
|
+
// ── ui: the LoopKit half ────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
export const SLOTS = [
|
|
119
|
+
'recordPanel',
|
|
120
|
+
'recordTab',
|
|
121
|
+
'recordAction',
|
|
122
|
+
'bulkAction',
|
|
123
|
+
'composerButton',
|
|
124
|
+
'messageAction',
|
|
125
|
+
'loopCard',
|
|
126
|
+
'linkCard',
|
|
127
|
+
'crmPanelItem',
|
|
128
|
+
'settingsPage',
|
|
129
|
+
'dashboardTile',
|
|
130
|
+
'simiTool',
|
|
131
|
+
] as const;
|
|
132
|
+
export const Slot = z.enum(SLOTS);
|
|
133
|
+
export type Slot = z.infer<typeof Slot>;
|
|
134
|
+
|
|
135
|
+
/** Things an app may do in Loops (`LoopKit.do`), and the scope each needs. */
|
|
136
|
+
export const DOES = { 'record.note': 'notes:write', 'loop.post': 'loops:write', 'action.add': 'actions:write' } as const;
|
|
137
|
+
export const Does = z.enum(Object.keys(DOES) as [keyof typeof DOES, ...(keyof typeof DOES)[]]);
|
|
138
|
+
|
|
139
|
+
/** A field the app reads: `contact.email`, `deal.value`, `loop.title`. */
|
|
140
|
+
export const FieldPath = z.string().regex(/^(contact|company|lead|deal|loop)\.[a-zA-Z][a-zA-Z0-9_]*$/, 'Like contact.email');
|
|
141
|
+
|
|
142
|
+
/** Where a sent value comes from: a granted field, the link, or the app's settings. */
|
|
143
|
+
export const ValueSource = z
|
|
144
|
+
.string()
|
|
145
|
+
.regex(/^((contact|company|lead|deal|loop)\.[a-zA-Z][a-zA-Z0-9_]*|link\.path\.[a-zA-Z][a-zA-Z0-9_]*|settings\.[a-zA-Z][a-zA-Z0-9_]*)$/);
|
|
146
|
+
|
|
147
|
+
/** A declared response field type: string, number, boolean, date, or enum:a|b|c. */
|
|
148
|
+
export const ReturnType = z.string().regex(/^(string|number|boolean|date|enum:[A-Za-z0-9_-]+(\|[A-Za-z0-9_-]+)*)$/);
|
|
149
|
+
|
|
150
|
+
export const Endpoint = z.object({
|
|
151
|
+
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
|
|
152
|
+
url: HttpsUrl,
|
|
153
|
+
sends: z.record(z.string(), ValueSource).default({}),
|
|
154
|
+
returns: z.record(z.string(), ReturnType),
|
|
155
|
+
});
|
|
156
|
+
export type Endpoint = z.infer<typeof Endpoint>;
|
|
157
|
+
|
|
158
|
+
export const Setting = z.discriminatedUnion('type', [
|
|
159
|
+
z.object({ type: z.literal('secret'), label: z.string().min(1).max(80) }),
|
|
160
|
+
z.object({ type: z.literal('text'), label: z.string().min(1).max(80) }),
|
|
161
|
+
z.object({ type: z.literal('choice'), label: z.string().min(1).max(80), options: z.array(z.string().min(1)).min(1) }),
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
export const UiManifest = z.object({
|
|
165
|
+
apiVersion: z.literal(1),
|
|
166
|
+
slots: z.array(Slot).min(1),
|
|
167
|
+
uses: z.array(FieldPath).default([]),
|
|
168
|
+
does: z.array(Does).default([]),
|
|
169
|
+
hears: z.array(EventTypeSchema).default([]),
|
|
170
|
+
/** Link patterns the app makes cards for, e.g. *.acme.com/report/* (needs the linkCard slot). */
|
|
171
|
+
links: z.array(z.string().min(3)).default([]),
|
|
172
|
+
settings: z.record(z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/), Setting).default({}),
|
|
173
|
+
endpoints: z.record(z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/), Endpoint).default({}),
|
|
174
|
+
/** Entry file of the LoopKit source, relative to the app folder. */
|
|
175
|
+
entry: z.string().default('src/app.tsx'),
|
|
176
|
+
});
|
|
177
|
+
export type UiManifest = z.infer<typeof UiManifest>;
|
|
178
|
+
|
|
179
|
+
// ── The manifest ────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
export const Developer = z.object({
|
|
182
|
+
name: z.string().min(1).max(80),
|
|
183
|
+
email: z.email(),
|
|
184
|
+
website: HttpsUrl.optional(),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export const Visibility = z.enum(['private', 'public']);
|
|
188
|
+
export type Visibility = z.infer<typeof Visibility>;
|
|
189
|
+
|
|
190
|
+
export const ManifestV1 = z
|
|
191
|
+
.object({
|
|
192
|
+
manifestVersion: z.literal(1),
|
|
193
|
+
id: AppId,
|
|
194
|
+
name: z.string().min(2).max(40),
|
|
195
|
+
version: SemVer,
|
|
196
|
+
summary: z.string().min(1).max(140),
|
|
197
|
+
description: z.string().max(4000).optional(),
|
|
198
|
+
developer: Developer,
|
|
199
|
+
visibility: Visibility.default('private'),
|
|
200
|
+
scopes: z.array(ScopeSchema).default([]),
|
|
201
|
+
sync: SyncManifest.optional(),
|
|
202
|
+
ui: UiManifest.optional(),
|
|
203
|
+
})
|
|
204
|
+
.superRefine((m, ctx) => {
|
|
205
|
+
if (!m.sync && !m.ui) ctx.addIssue({ code: 'custom', message: 'An app needs a sync half, a ui half, or both', path: [] });
|
|
206
|
+
const granted = new Set(expandScopes(m.scopes));
|
|
207
|
+
const need = (scope: Scope, path: (string | number)[], why: string) => {
|
|
208
|
+
if (!granted.has(scope)) ctx.addIssue({ code: 'custom', path, message: `${why} needs the ${scope} scope` });
|
|
209
|
+
};
|
|
210
|
+
if (new Set(m.scopes).size !== m.scopes.length) ctx.addIssue({ code: 'custom', path: ['scopes'], message: 'Scopes are listed twice' });
|
|
211
|
+
|
|
212
|
+
m.sync?.objects.forEach((o, i) => need(RECORD_SCOPE[o.ours].write, ['sync', 'objects', i, 'ours'], `Syncing ${o.ours}`));
|
|
213
|
+
|
|
214
|
+
if (m.ui) {
|
|
215
|
+
m.ui.uses.forEach((f, i) => need(RECORD_SCOPE[f.split('.')[0]].read, ['ui', 'uses', i], `Reading ${f}`));
|
|
216
|
+
m.ui.does.forEach((d, i) => need(DOES[d], ['ui', 'does', i], `Doing ${d}`));
|
|
217
|
+
m.ui.hears.forEach((e, i) => {
|
|
218
|
+
const s = scopeForEvent(e);
|
|
219
|
+
if (s) need(s, ['ui', 'hears', i], `Hearing ${e}`);
|
|
220
|
+
});
|
|
221
|
+
if (m.ui.links.length && !m.ui.slots.includes('linkCard'))
|
|
222
|
+
ctx.addIssue({ code: 'custom', path: ['ui', 'links'], message: 'Links need the linkCard slot' });
|
|
223
|
+
const uses = new Set(m.ui.uses);
|
|
224
|
+
const settings = m.ui.settings;
|
|
225
|
+
for (const [name, ep] of Object.entries(m.ui.endpoints)) {
|
|
226
|
+
for (const [arg, src] of Object.entries(ep.sends)) {
|
|
227
|
+
const path = ['ui', 'endpoints', name, 'sends', arg];
|
|
228
|
+
if (src.startsWith('settings.')) {
|
|
229
|
+
if (!settings[src.slice(9)]) ctx.addIssue({ code: 'custom', path, message: `${src} is not a declared setting` });
|
|
230
|
+
} else if (src.startsWith('link.')) {
|
|
231
|
+
if (!m.ui.slots.includes('linkCard')) ctx.addIssue({ code: 'custom', path, message: 'link values need the linkCard slot' });
|
|
232
|
+
} else if (!uses.has(src)) {
|
|
233
|
+
ctx.addIssue({ code: 'custom', path, message: `${src} is sent but not declared in ui.uses` });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
export type ManifestV1 = z.infer<typeof ManifestV1>;
|
|
240
|
+
export type ManifestV1Input = z.input<typeof ManifestV1>;
|
|
241
|
+
|
|
242
|
+
/** The manifest file name in an app folder. */
|
|
243
|
+
export const MANIFEST_FILE = 'loops.manifest.json';
|
|
244
|
+
|
|
245
|
+
/** Parse a manifest; errors come back as plain lines ("ui.uses.0: Reading contact.email needs the contacts:read scope"). */
|
|
246
|
+
export function parseManifest(input: unknown): { ok: true; manifest: ManifestV1 } | { ok: false; errors: string[] } {
|
|
247
|
+
const r = ManifestV1.safeParse(input);
|
|
248
|
+
if (r.success) return { ok: true, manifest: r.data };
|
|
249
|
+
return { ok: false, errors: r.error.issues.map((i) => `${i.path.join('.') || '(manifest)'}: ${i.message}`) };
|
|
250
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AppId, InstallId, SemVer, Timestamp, UserId, VersionId, WorkspaceId } from './ids.ts';
|
|
3
|
+
import { ManifestV1, Visibility } from './manifest.ts';
|
|
4
|
+
import { ScopeSchema } from './scopes.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Records the control plane (`backend-apps-registry`, D1) keeps, and the RPC surfaces Workers call each other through
|
|
8
|
+
* (Workers RPC: `WorkerEntrypoint` + service binding; POC passed 11 Nov 2026). Skill §3, §8a.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ── Review states (skill §8a) ───────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export const REVIEW_STATES = ['submitted', 'checking', 'ai_review', 'awaiting_human', 'approved', 'rejected'] as const;
|
|
14
|
+
export const ReviewState = z.enum(REVIEW_STATES);
|
|
15
|
+
export type ReviewState = z.infer<typeof ReviewState>;
|
|
16
|
+
|
|
17
|
+
/** Allowed moves. Anything else is refused by the registry. A failure at any step = rejected, instantly. */
|
|
18
|
+
export const REVIEW_TRANSITIONS: Record<ReviewState, readonly ReviewState[]> = {
|
|
19
|
+
submitted: ['checking', 'rejected'],
|
|
20
|
+
checking: ['ai_review', 'rejected'],
|
|
21
|
+
ai_review: ['awaiting_human', 'rejected'],
|
|
22
|
+
awaiting_human: ['approved', 'rejected'],
|
|
23
|
+
approved: [],
|
|
24
|
+
rejected: [],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function canMove(from: ReviewState, to: ReviewState): boolean {
|
|
28
|
+
return REVIEW_TRANSITIONS[from].includes(to);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Records ─────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export const AppRecord = z.object({
|
|
34
|
+
id: AppId,
|
|
35
|
+
name: z.string(),
|
|
36
|
+
summary: z.string(),
|
|
37
|
+
/** The workspace that owns the app (the developer's workspace). */
|
|
38
|
+
ownerWorkspaceId: WorkspaceId,
|
|
39
|
+
createdBy: UserId,
|
|
40
|
+
visibility: Visibility,
|
|
41
|
+
/** Latest approved version, shown in the Marketplace; null until one is approved. */
|
|
42
|
+
liveVersionId: VersionId.nullable(),
|
|
43
|
+
createdAt: Timestamp,
|
|
44
|
+
updatedAt: Timestamp,
|
|
45
|
+
});
|
|
46
|
+
export type AppRecord = z.infer<typeof AppRecord>;
|
|
47
|
+
|
|
48
|
+
export const AppVersionRecord = z.object({
|
|
49
|
+
id: VersionId,
|
|
50
|
+
appId: AppId,
|
|
51
|
+
version: SemVer,
|
|
52
|
+
manifest: ManifestV1,
|
|
53
|
+
state: ReviewState,
|
|
54
|
+
/** Plain-words reasons for a rejection, one per line item. */
|
|
55
|
+
reasons: z.array(z.string()).default([]),
|
|
56
|
+
/** Artifacts repo commit this version was built from (null in L0 until submit pushes source). */
|
|
57
|
+
sourceCommit: z.string().nullable(),
|
|
58
|
+
submittedBy: UserId,
|
|
59
|
+
submittedAt: Timestamp,
|
|
60
|
+
decidedBy: UserId.nullable(),
|
|
61
|
+
decidedAt: Timestamp.nullable(),
|
|
62
|
+
});
|
|
63
|
+
export type AppVersionRecord = z.infer<typeof AppVersionRecord>;
|
|
64
|
+
|
|
65
|
+
export const InstallStatus = z.enum(['active', 'paused', 'uninstalled']);
|
|
66
|
+
export type InstallStatus = z.infer<typeof InstallStatus>;
|
|
67
|
+
|
|
68
|
+
export const InstallRecord = z.object({
|
|
69
|
+
id: InstallId,
|
|
70
|
+
appId: AppId,
|
|
71
|
+
versionId: VersionId,
|
|
72
|
+
workspaceId: WorkspaceId,
|
|
73
|
+
installedBy: UserId,
|
|
74
|
+
/** Scopes the admin approved on the consent sheet. A new scope in a later version needs approval again. */
|
|
75
|
+
grantedScopes: z.array(ScopeSchema),
|
|
76
|
+
status: InstallStatus,
|
|
77
|
+
/** True for a `loops dev` install: visible only to that developer, never reviewed. */
|
|
78
|
+
dev: z.boolean().default(false),
|
|
79
|
+
createdAt: Timestamp,
|
|
80
|
+
updatedAt: Timestamp,
|
|
81
|
+
});
|
|
82
|
+
export type InstallRecord = z.infer<typeof InstallRecord>;
|
|
83
|
+
|
|
84
|
+
/** A Marketplace card: what the window lists. */
|
|
85
|
+
export const Listing = z.object({
|
|
86
|
+
appId: AppId,
|
|
87
|
+
name: z.string(),
|
|
88
|
+
summary: z.string(),
|
|
89
|
+
developerName: z.string(),
|
|
90
|
+
version: SemVer,
|
|
91
|
+
scopes: z.array(ScopeSchema),
|
|
92
|
+
installed: z.boolean(),
|
|
93
|
+
});
|
|
94
|
+
export type Listing = z.infer<typeof Listing>;
|
|
95
|
+
|
|
96
|
+
// ── Who is calling (resolved from Better Auth + /api/me at the edge) ────────────
|
|
97
|
+
|
|
98
|
+
export const Caller = z.object({
|
|
99
|
+
workspaceId: WorkspaceId,
|
|
100
|
+
userId: UserId,
|
|
101
|
+
role: z.enum(['owner', 'admin', 'member']),
|
|
102
|
+
developer: z.boolean(),
|
|
103
|
+
/** Recruitly SUPER user; the platform is behind a flag, SUPER only, in L0. */
|
|
104
|
+
superUser: z.boolean(),
|
|
105
|
+
});
|
|
106
|
+
export type Caller = z.infer<typeof Caller>;
|
|
107
|
+
|
|
108
|
+
// ── RPC surfaces (typed contracts for service bindings) ─────────────────────────
|
|
109
|
+
|
|
110
|
+
/** `backend-apps-registry` default WorkerEntrypoint. */
|
|
111
|
+
export interface RegistryRpc {
|
|
112
|
+
submit(caller: Caller, manifest: unknown): Promise<{ ok: true; version: AppVersionRecord } | { ok: false; errors: string[] }>;
|
|
113
|
+
listMarketplace(caller: Caller): Promise<Listing[]>;
|
|
114
|
+
/** Apps owned by the caller's workspace (`GET /v1/developer/apps`). Added in 0.2.0. */
|
|
115
|
+
listApps(caller: Caller): Promise<AppRecord[]>;
|
|
116
|
+
getApp(caller: Caller, appId: string): Promise<{ app: AppRecord; versions: AppVersionRecord[] } | null>;
|
|
117
|
+
install(caller: Caller, appId: string, grantedScopes: string[]): Promise<{ ok: true; install: InstallRecord } | { ok: false; error: string }>;
|
|
118
|
+
uninstall(caller: Caller, installId: string): Promise<{ ok: boolean }>;
|
|
119
|
+
listInstalls(caller: Caller): Promise<InstallRecord[]>;
|
|
120
|
+
/** Human review (System Admin, SUPER only). */
|
|
121
|
+
decide(caller: Caller, versionId: string, decision: 'approved' | 'rejected', reason?: string): Promise<{ ok: boolean; error?: string }>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `backend-apps-engine` default WorkerEntrypoint. */
|
|
125
|
+
export interface EngineRpc {
|
|
126
|
+
/** Wake the install's Agent after the registry wrote the install row. */
|
|
127
|
+
installed(install: InstallRecord): Promise<{ ok: true }>;
|
|
128
|
+
uninstalled(installId: string): Promise<{ ok: true }>;
|
|
129
|
+
health(): Promise<{ ok: true; version: string }>;
|
|
130
|
+
}
|
package/src/scopes.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every permission an app can ask for. One list for REST, MCP, events and LoopKit (skill §8 "one scope list").
|
|
5
|
+
* Only ever ADD to this list (versioning promise). `consent` is the plain-words line on the install sheet
|
|
6
|
+
* ("<App> can …"), sentence case.
|
|
7
|
+
*/
|
|
8
|
+
export const SCOPES = {
|
|
9
|
+
'contacts:read': 'See contacts: names, emails, phone numbers and their company',
|
|
10
|
+
'contacts:write': 'Add and change contacts',
|
|
11
|
+
'companies:read': 'See companies',
|
|
12
|
+
'companies:write': 'Add and change companies',
|
|
13
|
+
'leads:read': 'See leads',
|
|
14
|
+
'leads:write': 'Add and change leads',
|
|
15
|
+
'deals:read': 'See deals and the people on them',
|
|
16
|
+
'deals:write': 'Add and change deals',
|
|
17
|
+
'notes:write': 'Write notes on records, marked with the app’s name',
|
|
18
|
+
'actions:write': 'Add Actions for people in your workspace',
|
|
19
|
+
'loops:read': 'Read messages in loops the app is added to',
|
|
20
|
+
'loops:write': 'Post in loops the app is added to, marked with the app’s name',
|
|
21
|
+
'calls:read': 'See calls and their transcripts',
|
|
22
|
+
'meetings:read': 'See meetings and their notes',
|
|
23
|
+
'webhooks:manage': 'Send events from your workspace to the app',
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export type Scope = keyof typeof SCOPES;
|
|
27
|
+
export const SCOPE_NAMES = Object.keys(SCOPES) as [Scope, ...Scope[]];
|
|
28
|
+
export const ScopeSchema = z.enum(SCOPE_NAMES);
|
|
29
|
+
|
|
30
|
+
/** The consent line for a scope, e.g. on the install sheet. */
|
|
31
|
+
export function consentLine(scope: Scope): string {
|
|
32
|
+
return SCOPES[scope];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A `write` scope implies its `read` (asking for contacts:write shows both lines). */
|
|
36
|
+
export function expandScopes(scopes: readonly Scope[]): Scope[] {
|
|
37
|
+
const out = new Set<Scope>(scopes);
|
|
38
|
+
for (const s of scopes) {
|
|
39
|
+
const [res, op] = s.split(':');
|
|
40
|
+
const read = `${res}:read`;
|
|
41
|
+
if (op === 'write' && read in SCOPES) out.add(read as Scope);
|
|
42
|
+
}
|
|
43
|
+
return SCOPE_NAMES.filter((s) => out.has(s));
|
|
44
|
+
}
|
package/README.md
DELETED