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