@withloops.ai/contracts 0.0.0-stage → 0.1.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/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 +33 -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 +128 -0
- package/src/scopes.ts +44 -0
- package/README.md +0 -3
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifestVersion": 1,
|
|
3
|
+
"id": "acme-checks",
|
|
4
|
+
"name": "Acme Checks",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"summary": "Background checks on your contacts, started from the record.",
|
|
7
|
+
"developer": { "name": "Acme", "email": "dev@acme-checks.com", "website": "https://acme-checks.com" },
|
|
8
|
+
"visibility": "public",
|
|
9
|
+
"scopes": ["contacts:read", "notes:write"],
|
|
10
|
+
"ui": {
|
|
11
|
+
"apiVersion": 1,
|
|
12
|
+
"slots": ["recordTab", "linkCard"],
|
|
13
|
+
"uses": ["contact.firstName", "contact.email"],
|
|
14
|
+
"does": ["record.note"],
|
|
15
|
+
"hears": ["contact.updated"],
|
|
16
|
+
"links": ["*.acme-checks.com/report/*"],
|
|
17
|
+
"settings": { "apiKey": { "type": "secret", "label": "Your Acme API key" }, "package": { "type": "choice", "label": "Package", "options": ["Basic", "Enhanced"] } },
|
|
18
|
+
"endpoints": {
|
|
19
|
+
"checkStatus": { "method": "GET", "url": "https://api.acme-checks.com/v1/checks/{email}", "sends": { "email": "contact.email" }, "returns": { "status": "enum:clear|pending|flagged", "date": "date" } },
|
|
20
|
+
"startCheck": { "method": "POST", "url": "https://api.acme-checks.com/v1/checks", "sends": { "name": "contact.firstName", "email": "contact.email", "package": "settings.package" }, "returns": { "id": "string", "status": "enum:pending" } },
|
|
21
|
+
"report": { "method": "GET", "url": "https://api.acme-checks.com/v1/reports/{id}", "sends": { "id": "link.path.id" }, "returns": { "status": "enum:clear|pending|flagged" } }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifestVersion": 1,
|
|
3
|
+
"id": "acme-crm",
|
|
4
|
+
"name": "Acme CRM",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"summary": "Use Acme CRM as your CRM of record; Loops writes calls and notes to its timeline.",
|
|
7
|
+
"developer": {
|
|
8
|
+
"name": "Acme",
|
|
9
|
+
"email": "dev@acme-crm.com",
|
|
10
|
+
"website": "https://acme-crm.com"
|
|
11
|
+
},
|
|
12
|
+
"visibility": "public",
|
|
13
|
+
"scopes": [
|
|
14
|
+
"contacts:write",
|
|
15
|
+
"companies:write"
|
|
16
|
+
],
|
|
17
|
+
"sync": {
|
|
18
|
+
"baseUrl": "https://api.acme-crm.com",
|
|
19
|
+
"auth": {
|
|
20
|
+
"type": "oauth2",
|
|
21
|
+
"authorizeUrl": "https://app.acme-crm.com/oauth/authorize",
|
|
22
|
+
"tokenUrl": "https://api.acme-crm.com/oauth/token",
|
|
23
|
+
"scopes": [
|
|
24
|
+
"contacts",
|
|
25
|
+
"companies"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"objects": [
|
|
29
|
+
{
|
|
30
|
+
"theirs": "people",
|
|
31
|
+
"ours": "CONTACT",
|
|
32
|
+
"list": {
|
|
33
|
+
"path": "/v1/people",
|
|
34
|
+
"itemsPath": "data",
|
|
35
|
+
"cursorParam": "cursor",
|
|
36
|
+
"nextCursorPath": "next_cursor",
|
|
37
|
+
"updatedSinceParam": "updated_since"
|
|
38
|
+
},
|
|
39
|
+
"fields": [
|
|
40
|
+
{
|
|
41
|
+
"theirs": "first_name",
|
|
42
|
+
"ours": "firstName",
|
|
43
|
+
"owner": "theirs"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"theirs": "email",
|
|
47
|
+
"ours": "email",
|
|
48
|
+
"owner": "theirs"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"match": [
|
|
52
|
+
"email"
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"theirs": "organizations",
|
|
57
|
+
"ours": "COMPANY",
|
|
58
|
+
"list": {
|
|
59
|
+
"path": "/v1/organizations",
|
|
60
|
+
"itemsPath": "data",
|
|
61
|
+
"cursorParam": "cursor",
|
|
62
|
+
"nextCursorPath": "next_cursor"
|
|
63
|
+
},
|
|
64
|
+
"fields": [
|
|
65
|
+
{
|
|
66
|
+
"theirs": "name",
|
|
67
|
+
"ours": "name",
|
|
68
|
+
"owner": "theirs"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"theirs": "domain",
|
|
72
|
+
"ours": "website",
|
|
73
|
+
"owner": "theirs"
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
"match": [
|
|
77
|
+
"domain"
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
"links": [
|
|
82
|
+
{
|
|
83
|
+
"from": "CONTACT",
|
|
84
|
+
"to": "COMPANY",
|
|
85
|
+
"theirs": "organization_id"
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
"changeFeed": {
|
|
89
|
+
"webhook": {
|
|
90
|
+
"signature": {
|
|
91
|
+
"scheme": "hmac-sha256",
|
|
92
|
+
"header": "X-Acme-Signature"
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"polling": {
|
|
96
|
+
"everyMinutes": 15
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"writeBack": {
|
|
100
|
+
"call": {
|
|
101
|
+
"method": "POST",
|
|
102
|
+
"path": "/v1/activities"
|
|
103
|
+
},
|
|
104
|
+
"note": {
|
|
105
|
+
"method": "POST",
|
|
106
|
+
"path": "/v1/notes"
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"limits": {
|
|
110
|
+
"requestsPerSecond": 10,
|
|
111
|
+
"burst": 100
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifestVersion": 1,
|
|
3
|
+
"id": "hello",
|
|
4
|
+
"name": "Hello",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"summary": "Says hello on a contact.",
|
|
7
|
+
"developer": { "name": "Loops", "email": "developers@withloops.ai" },
|
|
8
|
+
"scopes": ["contacts:read"],
|
|
9
|
+
"ui": {
|
|
10
|
+
"apiVersion": 1,
|
|
11
|
+
"slots": ["recordPanel"],
|
|
12
|
+
"uses": ["contact.firstName"]
|
|
13
|
+
}
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@withloops.ai/contracts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Loops developer platform contracts (zod): app manifest v1, scopes, events, the /v1 API envelope and registry records. Everything else builds on these.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"examples"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test \"test/*.test.ts\"",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"zod": "^4.3.6"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"typescript": "^5.9.3",
|
|
26
|
+
"@types/node": "^24.0.0"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://withloops.ai",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"loops",
|
|
31
|
+
"withloops",
|
|
32
|
+
"sdk",
|
|
33
|
+
"contracts"
|
|
34
|
+
]
|
|
35
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { Timestamp } from './ids.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The public REST API envelope: `api.withloops.ai/v1`. One envelope for every resource, cursor paging, one error shape.
|
|
6
|
+
* Only ever ADD fields (versioning promise). Hono + @hono/zod-openapi build the OpenAPI spec from these schemas.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const API_VERSION = 'v1' as const;
|
|
10
|
+
|
|
11
|
+
// ── Errors ──────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/** Every error code the API returns, with its HTTP status. */
|
|
14
|
+
export const ERROR_CODES = {
|
|
15
|
+
bad_request: 400,
|
|
16
|
+
validation_failed: 422,
|
|
17
|
+
unauthorized: 401,
|
|
18
|
+
forbidden: 403,
|
|
19
|
+
missing_scope: 403,
|
|
20
|
+
not_found: 404,
|
|
21
|
+
conflict: 409,
|
|
22
|
+
rate_limited: 429,
|
|
23
|
+
internal: 500,
|
|
24
|
+
upstream_timeout: 504,
|
|
25
|
+
unavailable: 503,
|
|
26
|
+
} as const;
|
|
27
|
+
export type ErrorCode = keyof typeof ERROR_CODES;
|
|
28
|
+
export const ErrorCodeSchema = z.enum(Object.keys(ERROR_CODES) as [ErrorCode, ...ErrorCode[]]);
|
|
29
|
+
|
|
30
|
+
export const ApiError = z.object({
|
|
31
|
+
code: ErrorCodeSchema,
|
|
32
|
+
/** Plain words a developer can act on. Never a stack trace, never a secret. */
|
|
33
|
+
message: z.string(),
|
|
34
|
+
/** Field-level problems for validation_failed: { "email": "Not an email" }. */
|
|
35
|
+
fields: z.record(z.string(), z.string()).optional(),
|
|
36
|
+
/** The scope that was missing, for missing_scope. */
|
|
37
|
+
scope: z.string().optional(),
|
|
38
|
+
/** Echoed in the `x-request-id` header; quote it to support. */
|
|
39
|
+
requestId: z.string(),
|
|
40
|
+
});
|
|
41
|
+
export type ApiError = z.infer<typeof ApiError>;
|
|
42
|
+
|
|
43
|
+
export const ErrorEnvelope = z.object({ ok: z.literal(false), error: ApiError });
|
|
44
|
+
export type ErrorEnvelope = z.infer<typeof ErrorEnvelope>;
|
|
45
|
+
|
|
46
|
+
export function errorStatus(code: ErrorCode): number {
|
|
47
|
+
return ERROR_CODES[code];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Success ─────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export const Page = z.object({
|
|
53
|
+
/** Pass as `cursor` to get the next page; null on the last page. */
|
|
54
|
+
nextCursor: z.string().nullable(),
|
|
55
|
+
hasMore: z.boolean(),
|
|
56
|
+
});
|
|
57
|
+
export type Page = z.infer<typeof Page>;
|
|
58
|
+
|
|
59
|
+
/** Single object: `{ ok: true, data }`. */
|
|
60
|
+
export function dataEnvelope<T extends z.ZodType>(data: T) {
|
|
61
|
+
return z.object({ ok: z.literal(true), data });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** List: `{ ok: true, data: [...], page: { nextCursor, hasMore } }`. */
|
|
65
|
+
export function listEnvelope<T extends z.ZodType>(item: T) {
|
|
66
|
+
return z.object({ ok: z.literal(true), data: z.array(item), page: Page });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type DataEnvelope<T> = { ok: true; data: T };
|
|
70
|
+
export type ListEnvelope<T> = { ok: true; data: T[]; page: Page };
|
|
71
|
+
|
|
72
|
+
// ── Paging query ────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export const PAGE_LIMIT_DEFAULT = 25;
|
|
75
|
+
export const PAGE_LIMIT_MAX = 100;
|
|
76
|
+
|
|
77
|
+
export const ListQuery = z.object({
|
|
78
|
+
cursor: z.string().min(1).optional(),
|
|
79
|
+
limit: z.coerce.number().int().min(1).max(PAGE_LIMIT_MAX).default(PAGE_LIMIT_DEFAULT),
|
|
80
|
+
/** Only records changed at or after this time (sync and polling). */
|
|
81
|
+
updatedSince: Timestamp.optional(),
|
|
82
|
+
});
|
|
83
|
+
export type ListQuery = z.infer<typeof ListQuery>;
|
|
84
|
+
|
|
85
|
+
// ── Resources (L0: contacts) ────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/** A Loops contact as the public API shows it. camelCase, ids as strings, dates ISO. */
|
|
88
|
+
export const Contact = z.object({
|
|
89
|
+
id: z.string(),
|
|
90
|
+
firstName: z.string().nullable(),
|
|
91
|
+
lastName: z.string().nullable(),
|
|
92
|
+
email: z.email().nullable(),
|
|
93
|
+
phone: z.string().nullable(),
|
|
94
|
+
jobTitle: z.string().nullable(),
|
|
95
|
+
companyId: z.string().nullable(),
|
|
96
|
+
ownerId: z.string().nullable(),
|
|
97
|
+
createdAt: Timestamp,
|
|
98
|
+
updatedAt: Timestamp,
|
|
99
|
+
});
|
|
100
|
+
export type Contact = z.infer<typeof Contact>;
|
|
101
|
+
|
|
102
|
+
export const ContactCreate = z
|
|
103
|
+
.object({
|
|
104
|
+
firstName: z.string().max(120).optional(),
|
|
105
|
+
lastName: z.string().max(120).optional(),
|
|
106
|
+
email: z.email().optional(),
|
|
107
|
+
phone: z.string().max(40).optional(),
|
|
108
|
+
jobTitle: z.string().max(200).optional(),
|
|
109
|
+
companyId: z.string().optional(),
|
|
110
|
+
ownerId: z.string().optional(),
|
|
111
|
+
})
|
|
112
|
+
.refine((c) => c.firstName || c.lastName || c.email, 'Give a name or an email');
|
|
113
|
+
export type ContactCreate = z.infer<typeof ContactCreate>;
|
|
114
|
+
|
|
115
|
+
export const ContactUpdate = z.object({
|
|
116
|
+
firstName: z.string().max(120).nullable().optional(),
|
|
117
|
+
lastName: z.string().max(120).nullable().optional(),
|
|
118
|
+
email: z.email().nullable().optional(),
|
|
119
|
+
phone: z.string().max(40).nullable().optional(),
|
|
120
|
+
jobTitle: z.string().max(200).nullable().optional(),
|
|
121
|
+
companyId: z.string().nullable().optional(),
|
|
122
|
+
ownerId: z.string().nullable().optional(),
|
|
123
|
+
});
|
|
124
|
+
export type ContactUpdate = z.infer<typeof ContactUpdate>;
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AppId, EventId, InstallId, Timestamp, UserId, WorkspaceId } from './ids.ts';
|
|
3
|
+
import type { Scope } from './scopes.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every event an app can hear (LoopKit `hears`, webhooks out, the engine's queues). Each names the scope that lets an app
|
|
7
|
+
* hear it. Only ever ADD here (versioning promise).
|
|
8
|
+
*/
|
|
9
|
+
export const EVENTS = {
|
|
10
|
+
'contact.created': 'contacts:read',
|
|
11
|
+
'contact.updated': 'contacts:read',
|
|
12
|
+
'contact.deleted': 'contacts:read',
|
|
13
|
+
'company.created': 'companies:read',
|
|
14
|
+
'company.updated': 'companies:read',
|
|
15
|
+
'company.deleted': 'companies:read',
|
|
16
|
+
'lead.created': 'leads:read',
|
|
17
|
+
'lead.updated': 'leads:read',
|
|
18
|
+
'lead.deleted': 'leads:read',
|
|
19
|
+
'deal.created': 'deals:read',
|
|
20
|
+
'deal.updated': 'deals:read',
|
|
21
|
+
'deal.deleted': 'deals:read',
|
|
22
|
+
'loop.message': 'loops:read',
|
|
23
|
+
'loop.call.ended': 'calls:read',
|
|
24
|
+
'meeting.ended': 'meetings:read',
|
|
25
|
+
'app.installed': null,
|
|
26
|
+
'app.uninstalled': null,
|
|
27
|
+
} as const satisfies Record<string, Scope | null>;
|
|
28
|
+
|
|
29
|
+
export type EventType = keyof typeof EVENTS;
|
|
30
|
+
export const EVENT_TYPES = Object.keys(EVENTS) as [EventType, ...EventType[]];
|
|
31
|
+
export const EventTypeSchema = z.enum(EVENT_TYPES);
|
|
32
|
+
|
|
33
|
+
/** The scope needed to hear an event; null = every installed app hears it (about itself only). */
|
|
34
|
+
export function scopeForEvent(type: EventType): Scope | null {
|
|
35
|
+
return EVENTS[type];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Who caused a change. Apps never hear their own echo (`causedBy.kind === 'app'` and same app id). */
|
|
39
|
+
export const CausedBy = z.discriminatedUnion('kind', [
|
|
40
|
+
z.object({ kind: z.literal('user'), id: UserId }),
|
|
41
|
+
z.object({ kind: z.literal('app'), id: AppId }),
|
|
42
|
+
z.object({ kind: z.literal('system') }),
|
|
43
|
+
]);
|
|
44
|
+
export type CausedBy = z.infer<typeof CausedBy>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The one event envelope. `data` carries ids plus only the fields the app was granted; the engine trims it per install,
|
|
48
|
+
* so `data` is open here and each consumer narrows it.
|
|
49
|
+
*/
|
|
50
|
+
export const EventEnvelope = z.object({
|
|
51
|
+
id: EventId,
|
|
52
|
+
type: EventTypeSchema,
|
|
53
|
+
version: z.literal(1),
|
|
54
|
+
occurredAt: Timestamp,
|
|
55
|
+
workspaceId: WorkspaceId,
|
|
56
|
+
installId: InstallId.optional(),
|
|
57
|
+
causedBy: CausedBy,
|
|
58
|
+
data: z.record(z.string(), z.unknown()),
|
|
59
|
+
});
|
|
60
|
+
export type EventEnvelope = z.infer<typeof EventEnvelope>;
|
|
61
|
+
|
|
62
|
+
/** True when an app should not get this event back (it caused it). */
|
|
63
|
+
export function isEcho(event: Pick<EventEnvelope, 'causedBy'>, appId: string): boolean {
|
|
64
|
+
return event.causedBy.kind === 'app' && event.causedBy.id === appId;
|
|
65
|
+
}
|
package/src/ids.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** An app's id: the slug chosen at `loops create`, unique across Loops, never changes. */
|
|
4
|
+
export const AppId = z
|
|
5
|
+
.string()
|
|
6
|
+
.regex(/^[a-z][a-z0-9-]{1,38}[a-z0-9]$/, 'App id: 3–40 characters, lowercase letters, digits and dashes, starting with a letter');
|
|
7
|
+
export type AppId = z.infer<typeof AppId>;
|
|
8
|
+
|
|
9
|
+
/** Semantic version of one app release (`1.0.0`, `1.2.0-beta.1`). */
|
|
10
|
+
export const SemVer = z
|
|
11
|
+
.string()
|
|
12
|
+
.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?$/, 'Version must look like 1.0.0');
|
|
13
|
+
export type SemVer = z.infer<typeof SemVer>;
|
|
14
|
+
|
|
15
|
+
/** A Loops workspace (the customer side; Vista tenant id underneath). */
|
|
16
|
+
export const WorkspaceId = z.string().min(1).max(64);
|
|
17
|
+
export type WorkspaceId = z.infer<typeof WorkspaceId>;
|
|
18
|
+
|
|
19
|
+
/** A person in a workspace (Vista user id underneath). */
|
|
20
|
+
export const UserId = z.string().min(1).max(64);
|
|
21
|
+
export type UserId = z.infer<typeof UserId>;
|
|
22
|
+
|
|
23
|
+
/** Opaque ids minted by the registry. Prefix shows the kind at a glance in logs. */
|
|
24
|
+
export const InstallId = z.string().regex(/^ins_[A-Za-z0-9]{8,40}$/);
|
|
25
|
+
export type InstallId = z.infer<typeof InstallId>;
|
|
26
|
+
|
|
27
|
+
export const VersionId = z.string().regex(/^ver_[A-Za-z0-9]{8,40}$/);
|
|
28
|
+
export type VersionId = z.infer<typeof VersionId>;
|
|
29
|
+
|
|
30
|
+
export const EventId = z.string().regex(/^evt_[A-Za-z0-9]{8,40}$/);
|
|
31
|
+
export type EventId = z.infer<typeof EventId>;
|
|
32
|
+
|
|
33
|
+
/** ISO 8601 timestamp with offset, e.g. 2026-11-11T09:30:00Z. */
|
|
34
|
+
export const Timestamp = z.iso.datetime({ offset: true });
|
|
35
|
+
export type Timestamp = z.infer<typeof Timestamp>;
|
|
36
|
+
|
|
37
|
+
/** Public https URL. http is refused everywhere in the platform. */
|
|
38
|
+
export const HttpsUrl = z.url({ protocol: /^https$/ });
|
|
39
|
+
export type HttpsUrl = z.infer<typeof HttpsUrl>;
|
package/src/index.ts
ADDED
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,128 @@
|
|
|
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
|
+
getApp(caller: Caller, appId: string): Promise<{ app: AppRecord; versions: AppVersionRecord[] } | null>;
|
|
115
|
+
install(caller: Caller, appId: string, grantedScopes: string[]): Promise<{ ok: true; install: InstallRecord } | { ok: false; error: string }>;
|
|
116
|
+
uninstall(caller: Caller, installId: string): Promise<{ ok: boolean }>;
|
|
117
|
+
listInstalls(caller: Caller): Promise<InstallRecord[]>;
|
|
118
|
+
/** Human review (System Admin, SUPER only). */
|
|
119
|
+
decide(caller: Caller, versionId: string, decision: 'approved' | 'rejected', reason?: string): Promise<{ ok: boolean; error?: string }>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `backend-apps-engine` default WorkerEntrypoint. */
|
|
123
|
+
export interface EngineRpc {
|
|
124
|
+
/** Wake the install's Agent after the registry wrote the install row. */
|
|
125
|
+
installed(install: InstallRecord): Promise<{ ok: true }>;
|
|
126
|
+
uninstalled(installId: string): Promise<{ ok: true }>;
|
|
127
|
+
health(): Promise<{ ok: true; version: string }>;
|
|
128
|
+
}
|
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