@withloops.ai/contracts 0.2.1 → 0.3.1

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/index.d.ts CHANGED
@@ -4,3 +4,4 @@ export * from './events.ts';
4
4
  export * from './manifest.ts';
5
5
  export * from './api.ts';
6
6
  export * from './registry.ts';
7
+ export * from './trust.ts';
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export * from "./events.js";
4
4
  export * from "./manifest.js";
5
5
  export * from "./api.js";
6
6
  export * from "./registry.js";
7
+ export * from "./trust.js";
@@ -16,6 +16,7 @@ export declare const SyncAuth: z.ZodDiscriminatedUnion<[z.ZodObject<{
16
16
  type: z.ZodLiteral<"oauth2">;
17
17
  authorizeUrl: z.ZodURL;
18
18
  tokenUrl: z.ZodURL;
19
+ clientId: z.ZodOptional<z.ZodString>;
19
20
  scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
20
21
  refresh: z.ZodDefault<z.ZodBoolean>;
21
22
  }, z.core.$strip>, z.ZodObject<{
@@ -127,6 +128,7 @@ export declare const SyncManifest: z.ZodObject<{
127
128
  type: z.ZodLiteral<"oauth2">;
128
129
  authorizeUrl: z.ZodURL;
129
130
  tokenUrl: z.ZodURL;
131
+ clientId: z.ZodOptional<z.ZodString>;
130
132
  scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
131
133
  refresh: z.ZodDefault<z.ZodBoolean>;
132
134
  }, z.core.$strip>, z.ZodObject<{
@@ -385,6 +387,7 @@ export declare const ManifestV1: z.ZodObject<{
385
387
  type: z.ZodLiteral<"oauth2">;
386
388
  authorizeUrl: z.ZodURL;
387
389
  tokenUrl: z.ZodURL;
390
+ clientId: z.ZodOptional<z.ZodString>;
388
391
  scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
389
392
  refresh: z.ZodDefault<z.ZodBoolean>;
390
393
  }, z.core.$strip>, z.ZodObject<{
package/dist/manifest.js CHANGED
@@ -28,6 +28,8 @@ export const SyncAuth = z.discriminatedUnion('type', [
28
28
  type: z.literal('oauth2'),
29
29
  authorizeUrl: HttpsUrl,
30
30
  tokenUrl: HttpsUrl,
31
+ /** The developer's OAuth client id at their own service (public). The secret never goes in the manifest: it lives in the vault (APP_CLIENT_SECRET_REF). Added in 0.3.1. */
32
+ clientId: z.string().min(1).max(256).optional(),
31
33
  scopes: z.array(z.string().min(1)).default([]),
32
34
  refresh: z.boolean().default(true),
33
35
  }),
@@ -74,6 +74,7 @@ export declare const AppVersionRecord: z.ZodObject<{
74
74
  type: z.ZodLiteral<"oauth2">;
75
75
  authorizeUrl: z.ZodURL;
76
76
  tokenUrl: z.ZodURL;
77
+ clientId: z.ZodOptional<z.ZodString>;
77
78
  scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
78
79
  refresh: z.ZodDefault<z.ZodBoolean>;
79
80
  }, z.core.$strip>, z.ZodObject<{
@@ -0,0 +1,206 @@
1
+ import { z } from 'zod';
2
+ import { AppRecord, Caller } from './registry.ts';
3
+ /**
4
+ * Layer 1 (Trust): Loops as an OAuth provider, consent, the audit log, the developer switch and the apps vault.
5
+ * Skill devops-loops-developer-platform §1 (developer access), §6 (vault), §7 (OAuth both ways). Only ever ADD.
6
+ */
7
+ /** The issuer. Discovery at `${LOOPS_OAUTH_ISSUER}/.well-known/oauth-authorization-server`. */
8
+ export declare const LOOPS_OAUTH_ISSUER = "https://api.withloops.ai";
9
+ export declare const OAUTH_PATHS: {
10
+ readonly authorize: "/oauth/authorize";
11
+ readonly token: "/oauth/token";
12
+ readonly register: "/oauth/register";
13
+ };
14
+ /** The `loops` CLI: a public client (PKCE, loopback redirect on 127.0.0.1, no secret). */
15
+ export declare const LOOPS_CLI_CLIENT_ID = "loops-cli";
16
+ /** The scope the CLI asks for: build, submit and dev-install apps as the signed-in developer. */
17
+ export declare const DEVELOPER_SCOPE: "developer";
18
+ export declare const OAuthClientRecord: z.ZodObject<{
19
+ clientId: z.ZodString;
20
+ appId: z.ZodNullable<z.ZodString>;
21
+ name: z.ZodString;
22
+ redirectUris: z.ZodArray<z.ZodString>;
23
+ createdBy: z.ZodString;
24
+ createdAt: z.ZodISODateTime;
25
+ }, z.core.$strip>;
26
+ export type OAuthClientRecord = z.infer<typeof OAuthClientRecord>;
27
+ /** Returned ONCE when a client is minted (`loops create`); the registry keeps only a hash of the secret. */
28
+ export declare const MintedClient: z.ZodObject<{
29
+ clientId: z.ZodString;
30
+ appId: z.ZodNullable<z.ZodString>;
31
+ name: z.ZodString;
32
+ redirectUris: z.ZodArray<z.ZodString>;
33
+ createdBy: z.ZodString;
34
+ createdAt: z.ZodISODateTime;
35
+ clientSecret: z.ZodString;
36
+ }, z.core.$strip>;
37
+ export type MintedClient = z.infer<typeof MintedClient>;
38
+ export declare const CreateAppInput: z.ZodObject<{
39
+ id: z.ZodString;
40
+ name: z.ZodString;
41
+ redirectUris: z.ZodDefault<z.ZodArray<z.ZodString>>;
42
+ }, z.core.$strip>;
43
+ export type CreateAppInput = z.infer<typeof CreateAppInput>;
44
+ /** What a Loops access token stands for, resolved by the registry for backend-api `/v1`. */
45
+ export declare const TokenCaller: z.ZodObject<{
46
+ caller: z.ZodObject<{
47
+ workspaceId: z.ZodString;
48
+ userId: z.ZodString;
49
+ role: z.ZodEnum<{
50
+ owner: "owner";
51
+ admin: "admin";
52
+ member: "member";
53
+ }>;
54
+ developer: z.ZodBoolean;
55
+ superUser: z.ZodBoolean;
56
+ }, z.core.$strip>;
57
+ scopes: z.ZodArray<z.ZodString>;
58
+ clientId: z.ZodString;
59
+ appId: z.ZodNullable<z.ZodString>;
60
+ installId: z.ZodNullable<z.ZodString>;
61
+ }, z.core.$strip>;
62
+ export type TokenCaller = z.infer<typeof TokenCaller>;
63
+ /** One row per consent sheet answered: who approved which scopes for which version. */
64
+ export declare const ConsentRecord: z.ZodObject<{
65
+ id: z.ZodString;
66
+ installId: z.ZodString;
67
+ appId: z.ZodString;
68
+ versionId: z.ZodString;
69
+ workspaceId: z.ZodString;
70
+ grantedBy: z.ZodString;
71
+ scopes: z.ZodArray<z.ZodEnum<{
72
+ "contacts:read": "contacts:read";
73
+ "contacts:write": "contacts:write";
74
+ "companies:read": "companies:read";
75
+ "companies:write": "companies:write";
76
+ "leads:read": "leads:read";
77
+ "leads:write": "leads:write";
78
+ "deals:read": "deals:read";
79
+ "deals:write": "deals:write";
80
+ "notes:write": "notes:write";
81
+ "actions:write": "actions:write";
82
+ "loops:read": "loops:read";
83
+ "loops:write": "loops:write";
84
+ "calls:read": "calls:read";
85
+ "meetings:read": "meetings:read";
86
+ "webhooks:manage": "webhooks:manage";
87
+ }>>;
88
+ consentedAt: z.ZodISODateTime;
89
+ }, z.core.$strip>;
90
+ export type ConsentRecord = z.infer<typeof ConsentRecord>;
91
+ export declare const AuditEntry: z.ZodObject<{
92
+ id: z.ZodNumber;
93
+ at: z.ZodISODateTime;
94
+ actor: z.ZodString;
95
+ workspaceId: z.ZodNullable<z.ZodString>;
96
+ appId: z.ZodNullable<z.ZodString>;
97
+ versionId: z.ZodNullable<z.ZodString>;
98
+ installId: z.ZodNullable<z.ZodString>;
99
+ action: z.ZodString;
100
+ detail: z.ZodRecord<z.ZodString, z.ZodUnknown>;
101
+ }, z.core.$strip>;
102
+ export type AuditEntry = z.infer<typeof AuditEntry>;
103
+ export declare const AuditQuery: z.ZodObject<{
104
+ appId: z.ZodOptional<z.ZodString>;
105
+ installId: z.ZodOptional<z.ZodString>;
106
+ cursor: z.ZodOptional<z.ZodString>;
107
+ limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
108
+ }, z.core.$strip>;
109
+ export type AuditQuery = z.infer<typeof AuditQuery>;
110
+ export declare const WorkspaceDeveloper: z.ZodObject<{
111
+ workspaceId: z.ZodString;
112
+ userId: z.ZodString;
113
+ grantedBy: z.ZodString;
114
+ grantedAt: z.ZodISODateTime;
115
+ }, z.core.$strip>;
116
+ export type WorkspaceDeveloper = z.infer<typeof WorkspaceDeveloper>;
117
+ /** `access_token`, `refresh_token`, `api_key`, `webhook_secret`… */
118
+ export declare const SecretName: z.ZodString;
119
+ export type SecretName = z.infer<typeof SecretName>;
120
+ /** Where a secret belongs. All four parts are the encryption's additional data, so a copied row will not decrypt. */
121
+ export declare const SecretRef: z.ZodObject<{
122
+ installId: z.ZodString;
123
+ workspaceId: z.ZodString;
124
+ appId: z.ZodString;
125
+ name: z.ZodString;
126
+ }, z.core.$strip>;
127
+ export type SecretRef = z.infer<typeof SecretRef>;
128
+ /**
129
+ * The developer's OAuth client secret for their own service is stored once per app under this reserved install id,
130
+ * in the app owner's workspace: `{ installId: APP_CLIENT_INSTALL_ID, workspaceId: app.ownerWorkspaceId, appId, name: 'oauth_client_secret' }`.
131
+ * Uninstall never deletes it; only deleting the app does. Added in 0.3.1.
132
+ */
133
+ export declare const APP_CLIENT_INSTALL_ID: "ins_appclient";
134
+ export declare const APP_CLIENT_SECRET_NAME: "oauth_client_secret";
135
+ export declare function appClientSecretRef(appId: string, ownerWorkspaceId: string): SecretRef;
136
+ /** Engine-side code exchange for an app connecting OUT (the handoff at api.thesoogroup.com/oauth/callback). Added in 0.3.1. */
137
+ export declare const OAuthCodeExchange: z.ZodObject<{
138
+ install: z.ZodObject<{
139
+ workspaceId: z.ZodString;
140
+ installId: z.ZodString;
141
+ appId: z.ZodString;
142
+ }, z.core.$strip>;
143
+ ownerWorkspaceId: z.ZodString;
144
+ tokenUrl: z.ZodString;
145
+ clientId: z.ZodString;
146
+ code: z.ZodString;
147
+ codeVerifier: z.ZodOptional<z.ZodString>;
148
+ redirectUri: z.ZodString;
149
+ }, z.core.$strip>;
150
+ export type OAuthCodeExchange = z.infer<typeof OAuthCodeExchange>;
151
+ /** What anyone outside the engine may see about a secret: never the value. */
152
+ export declare const SecretInfo: z.ZodObject<{
153
+ name: z.ZodString;
154
+ keyVersion: z.ZodString;
155
+ expiresAt: z.ZodNullable<z.ZodISODateTime>;
156
+ updatedAt: z.ZodISODateTime;
157
+ }, z.core.$strip>;
158
+ export type SecretInfo = z.infer<typeof SecretInfo>;
159
+ /** `backend-apps-registry` default WorkerEntrypoint implements this as well as `RegistryRpc`. */
160
+ export interface RegistryTrustRpc {
161
+ /** Create an app owned by the caller's workspace and mint its OAuth client (`loops create`). Developer only. */
162
+ createApp(caller: Caller, input: CreateAppInput): Promise<{
163
+ ok: true;
164
+ app: AppRecord;
165
+ client: MintedClient;
166
+ } | {
167
+ ok: false;
168
+ error: string;
169
+ }>;
170
+ /** Resolve a Loops OAuth access token (`Authorization: Bearer …`) for `/v1`; null when unknown, expired or revoked. */
171
+ resolveToken(bearer: string): Promise<TokenCaller | null>;
172
+ /** True when the user has the developer switch on in that workspace. */
173
+ isDeveloper(workspaceId: string, userId: string): Promise<boolean>;
174
+ /** Owner/Admin only. */
175
+ setDeveloper(caller: Caller, userId: string, on: boolean): Promise<{
176
+ ok: boolean;
177
+ error?: string;
178
+ }>;
179
+ listDevelopers(caller: Caller): Promise<WorkspaceDeveloper[]>;
180
+ /** Consents for one install of the caller's workspace, newest first. */
181
+ listConsents(caller: Caller, installId: string): Promise<ConsentRecord[]>;
182
+ /** Audit rows for the caller's workspace (SUPER sees all), newest first. */
183
+ listAudit(caller: Caller, query: AuditQuery): Promise<{
184
+ items: AuditEntry[];
185
+ nextCursor: string | null;
186
+ }>;
187
+ }
188
+ /** `backend-apps-engine` default WorkerEntrypoint implements this as well as `EngineRpc`. Values go in, never out. */
189
+ export interface EngineVaultRpc {
190
+ putSecret(ref: SecretRef, value: string, expiresAt?: string | null): Promise<{
191
+ ok: true;
192
+ }>;
193
+ listSecrets(installId: string): Promise<SecretInfo[]>;
194
+ /** Uninstall calls this; returns how many rows went. */
195
+ deleteSecrets(installId: string): Promise<{
196
+ deleted: number;
197
+ }>;
198
+ /** Reads the app's client secret, POSTs to tokenUrl, stores access/refresh tokens for the install itself. Values never come back. Added in 0.3.1. */
199
+ exchangeOAuthCode(input: OAuthCodeExchange): Promise<{
200
+ ok: true;
201
+ expiresAt: string | null;
202
+ } | {
203
+ ok: false;
204
+ error: string;
205
+ }>;
206
+ }
package/dist/trust.js ADDED
@@ -0,0 +1,121 @@
1
+ import { z } from 'zod';
2
+ import { AppId, InstallId, Timestamp, UserId, VersionId, WorkspaceId } from "./ids.js";
3
+ import { ScopeSchema } from "./scopes.js";
4
+ import { AppRecord, Caller } from "./registry.js";
5
+ /**
6
+ * Layer 1 (Trust): Loops as an OAuth provider, consent, the audit log, the developer switch and the apps vault.
7
+ * Skill devops-loops-developer-platform §1 (developer access), §6 (vault), §7 (OAuth both ways). Only ever ADD.
8
+ */
9
+ // ── Loops as an OAuth provider (@cloudflare/workers-oauth-provider 1.x in backend-apps-registry) ──
10
+ /** The issuer. Discovery at `${LOOPS_OAUTH_ISSUER}/.well-known/oauth-authorization-server`. */
11
+ export const LOOPS_OAUTH_ISSUER = 'https://api.withloops.ai';
12
+ export const OAUTH_PATHS = {
13
+ authorize: '/oauth/authorize',
14
+ token: '/oauth/token',
15
+ register: '/oauth/register',
16
+ };
17
+ /** The `loops` CLI: a public client (PKCE, loopback redirect on 127.0.0.1, no secret). */
18
+ export const LOOPS_CLI_CLIENT_ID = 'loops-cli';
19
+ /** The scope the CLI asks for: build, submit and dev-install apps as the signed-in developer. */
20
+ export const DEVELOPER_SCOPE = 'developer';
21
+ export const OAuthClientRecord = z.object({
22
+ clientId: z.string().min(8).max(128),
23
+ /** The app this client belongs to; null for first-party clients (the CLI). */
24
+ appId: AppId.nullable(),
25
+ name: z.string(),
26
+ /** https only, except loopback (127.0.0.1 / [::1]) for public native clients. */
27
+ redirectUris: z.array(z.string().url()),
28
+ createdBy: UserId,
29
+ createdAt: Timestamp,
30
+ });
31
+ /** Returned ONCE when a client is minted (`loops create`); the registry keeps only a hash of the secret. */
32
+ export const MintedClient = OAuthClientRecord.extend({ clientSecret: z.string().min(32) });
33
+ export const CreateAppInput = z.object({
34
+ id: AppId,
35
+ name: z.string().min(1).max(60),
36
+ redirectUris: z.array(z.string().url()).default([]),
37
+ });
38
+ /** What a Loops access token stands for, resolved by the registry for backend-api `/v1`. */
39
+ export const TokenCaller = z.object({
40
+ caller: Caller,
41
+ /** Scopes the token carries: app scopes, or `developer` for the CLI. */
42
+ scopes: z.array(z.string()),
43
+ clientId: z.string(),
44
+ /** Set when the token was issued to an app for one install. */
45
+ appId: AppId.nullable(),
46
+ installId: InstallId.nullable(),
47
+ });
48
+ // ── Consent and audit ─────────────────────────────────────────────────────────
49
+ /** One row per consent sheet answered: who approved which scopes for which version. */
50
+ export const ConsentRecord = z.object({
51
+ id: z.string().regex(/^cns_[A-Za-z0-9]{8,40}$/),
52
+ installId: InstallId,
53
+ appId: AppId,
54
+ versionId: VersionId,
55
+ workspaceId: WorkspaceId,
56
+ grantedBy: UserId,
57
+ scopes: z.array(ScopeSchema),
58
+ consentedAt: Timestamp,
59
+ });
60
+ export const AuditEntry = z.object({
61
+ id: z.number().int(),
62
+ at: Timestamp,
63
+ actor: z.string(),
64
+ workspaceId: WorkspaceId.nullable(),
65
+ appId: AppId.nullable(),
66
+ versionId: VersionId.nullable(),
67
+ installId: InstallId.nullable(),
68
+ /** e.g. `install.created`, `consent.granted`, `developer.granted`, `oauth.client_created`. */
69
+ action: z.string(),
70
+ detail: z.record(z.string(), z.unknown()),
71
+ });
72
+ export const AuditQuery = z.object({
73
+ appId: AppId.optional(),
74
+ installId: InstallId.optional(),
75
+ cursor: z.string().optional(),
76
+ limit: z.coerce.number().int().min(1).max(100).default(25),
77
+ });
78
+ // ── The developer switch ────────────────────────────────────────────────────
79
+ export const WorkspaceDeveloper = z.object({
80
+ workspaceId: WorkspaceId,
81
+ userId: UserId,
82
+ grantedBy: UserId,
83
+ grantedAt: Timestamp,
84
+ });
85
+ // ── The apps vault (backend-apps-engine, D1 apps-loops-vault) ────────────────
86
+ /** `access_token`, `refresh_token`, `api_key`, `webhook_secret`… */
87
+ export const SecretName = z.string().regex(/^[a-z][a-z0-9_]{0,62}$/, 'Secret name: lowercase letters, digits and underscores');
88
+ /** Where a secret belongs. All four parts are the encryption's additional data, so a copied row will not decrypt. */
89
+ export const SecretRef = z.object({
90
+ installId: InstallId,
91
+ workspaceId: WorkspaceId,
92
+ appId: AppId,
93
+ name: SecretName,
94
+ });
95
+ /**
96
+ * The developer's OAuth client secret for their own service is stored once per app under this reserved install id,
97
+ * in the app owner's workspace: `{ installId: APP_CLIENT_INSTALL_ID, workspaceId: app.ownerWorkspaceId, appId, name: 'oauth_client_secret' }`.
98
+ * Uninstall never deletes it; only deleting the app does. Added in 0.3.1.
99
+ */
100
+ export const APP_CLIENT_INSTALL_ID = 'ins_appclient';
101
+ export const APP_CLIENT_SECRET_NAME = 'oauth_client_secret';
102
+ export function appClientSecretRef(appId, ownerWorkspaceId) {
103
+ return { installId: APP_CLIENT_INSTALL_ID, workspaceId: ownerWorkspaceId, appId, name: APP_CLIENT_SECRET_NAME };
104
+ }
105
+ /** Engine-side code exchange for an app connecting OUT (the handoff at api.thesoogroup.com/oauth/callback). Added in 0.3.1. */
106
+ export const OAuthCodeExchange = z.object({
107
+ install: SecretRef.omit({ name: true }),
108
+ ownerWorkspaceId: WorkspaceId,
109
+ tokenUrl: z.string().url(),
110
+ clientId: z.string().min(1),
111
+ code: z.string().min(1),
112
+ codeVerifier: z.string().min(43).max(128).optional(),
113
+ redirectUri: z.string().url(),
114
+ });
115
+ /** What anyone outside the engine may see about a secret: never the value. */
116
+ export const SecretInfo = z.object({
117
+ name: SecretName,
118
+ keyVersion: z.string(),
119
+ expiresAt: Timestamp.nullable(),
120
+ updatedAt: Timestamp,
121
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withloops.ai/contracts",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
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
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/index.ts CHANGED
@@ -4,3 +4,4 @@ export * from './events.ts';
4
4
  export * from './manifest.ts';
5
5
  export * from './api.ts';
6
6
  export * from './registry.ts';
7
+ export * from './trust.ts';
package/src/manifest.ts CHANGED
@@ -36,6 +36,8 @@ export const SyncAuth = z.discriminatedUnion('type', [
36
36
  type: z.literal('oauth2'),
37
37
  authorizeUrl: HttpsUrl,
38
38
  tokenUrl: HttpsUrl,
39
+ /** The developer's OAuth client id at their own service (public). The secret never goes in the manifest: it lives in the vault (APP_CLIENT_SECRET_REF). Added in 0.3.1. */
40
+ clientId: z.string().min(1).max(256).optional(),
39
41
  scopes: z.array(z.string().min(1)).default([]),
40
42
  refresh: z.boolean().default(true),
41
43
  }),
package/src/trust.ts ADDED
@@ -0,0 +1,184 @@
1
+ import { z } from 'zod';
2
+ import { AppId, InstallId, Timestamp, UserId, VersionId, WorkspaceId } from './ids.ts';
3
+ import { ScopeSchema } from './scopes.ts';
4
+ import { AppRecord, Caller } from './registry.ts';
5
+
6
+ /**
7
+ * Layer 1 (Trust): Loops as an OAuth provider, consent, the audit log, the developer switch and the apps vault.
8
+ * Skill devops-loops-developer-platform §1 (developer access), §6 (vault), §7 (OAuth both ways). Only ever ADD.
9
+ */
10
+
11
+ // ── Loops as an OAuth provider (@cloudflare/workers-oauth-provider 1.x in backend-apps-registry) ──
12
+
13
+ /** The issuer. Discovery at `${LOOPS_OAUTH_ISSUER}/.well-known/oauth-authorization-server`. */
14
+ export const LOOPS_OAUTH_ISSUER = 'https://api.withloops.ai';
15
+
16
+ export const OAUTH_PATHS = {
17
+ authorize: '/oauth/authorize',
18
+ token: '/oauth/token',
19
+ register: '/oauth/register',
20
+ } as const;
21
+
22
+ /** The `loops` CLI: a public client (PKCE, loopback redirect on 127.0.0.1, no secret). */
23
+ export const LOOPS_CLI_CLIENT_ID = 'loops-cli';
24
+
25
+ /** The scope the CLI asks for: build, submit and dev-install apps as the signed-in developer. */
26
+ export const DEVELOPER_SCOPE = 'developer' as const;
27
+
28
+ export const OAuthClientRecord = z.object({
29
+ clientId: z.string().min(8).max(128),
30
+ /** The app this client belongs to; null for first-party clients (the CLI). */
31
+ appId: AppId.nullable(),
32
+ name: z.string(),
33
+ /** https only, except loopback (127.0.0.1 / [::1]) for public native clients. */
34
+ redirectUris: z.array(z.string().url()),
35
+ createdBy: UserId,
36
+ createdAt: Timestamp,
37
+ });
38
+ export type OAuthClientRecord = z.infer<typeof OAuthClientRecord>;
39
+
40
+ /** Returned ONCE when a client is minted (`loops create`); the registry keeps only a hash of the secret. */
41
+ export const MintedClient = OAuthClientRecord.extend({ clientSecret: z.string().min(32) });
42
+ export type MintedClient = z.infer<typeof MintedClient>;
43
+
44
+ export const CreateAppInput = z.object({
45
+ id: AppId,
46
+ name: z.string().min(1).max(60),
47
+ redirectUris: z.array(z.string().url()).default([]),
48
+ });
49
+ export type CreateAppInput = z.infer<typeof CreateAppInput>;
50
+
51
+ /** What a Loops access token stands for, resolved by the registry for backend-api `/v1`. */
52
+ export const TokenCaller = z.object({
53
+ caller: Caller,
54
+ /** Scopes the token carries: app scopes, or `developer` for the CLI. */
55
+ scopes: z.array(z.string()),
56
+ clientId: z.string(),
57
+ /** Set when the token was issued to an app for one install. */
58
+ appId: AppId.nullable(),
59
+ installId: InstallId.nullable(),
60
+ });
61
+ export type TokenCaller = z.infer<typeof TokenCaller>;
62
+
63
+ // ── Consent and audit ─────────────────────────────────────────────────────────
64
+
65
+ /** One row per consent sheet answered: who approved which scopes for which version. */
66
+ export const ConsentRecord = z.object({
67
+ id: z.string().regex(/^cns_[A-Za-z0-9]{8,40}$/),
68
+ installId: InstallId,
69
+ appId: AppId,
70
+ versionId: VersionId,
71
+ workspaceId: WorkspaceId,
72
+ grantedBy: UserId,
73
+ scopes: z.array(ScopeSchema),
74
+ consentedAt: Timestamp,
75
+ });
76
+ export type ConsentRecord = z.infer<typeof ConsentRecord>;
77
+
78
+ export const AuditEntry = z.object({
79
+ id: z.number().int(),
80
+ at: Timestamp,
81
+ actor: z.string(),
82
+ workspaceId: WorkspaceId.nullable(),
83
+ appId: AppId.nullable(),
84
+ versionId: VersionId.nullable(),
85
+ installId: InstallId.nullable(),
86
+ /** e.g. `install.created`, `consent.granted`, `developer.granted`, `oauth.client_created`. */
87
+ action: z.string(),
88
+ detail: z.record(z.string(), z.unknown()),
89
+ });
90
+ export type AuditEntry = z.infer<typeof AuditEntry>;
91
+
92
+ export const AuditQuery = z.object({
93
+ appId: AppId.optional(),
94
+ installId: InstallId.optional(),
95
+ cursor: z.string().optional(),
96
+ limit: z.coerce.number().int().min(1).max(100).default(25),
97
+ });
98
+ export type AuditQuery = z.infer<typeof AuditQuery>;
99
+
100
+ // ── The developer switch ────────────────────────────────────────────────────
101
+
102
+ export const WorkspaceDeveloper = z.object({
103
+ workspaceId: WorkspaceId,
104
+ userId: UserId,
105
+ grantedBy: UserId,
106
+ grantedAt: Timestamp,
107
+ });
108
+ export type WorkspaceDeveloper = z.infer<typeof WorkspaceDeveloper>;
109
+
110
+ // ── The apps vault (backend-apps-engine, D1 apps-loops-vault) ────────────────
111
+
112
+ /** `access_token`, `refresh_token`, `api_key`, `webhook_secret`… */
113
+ export const SecretName = z.string().regex(/^[a-z][a-z0-9_]{0,62}$/, 'Secret name: lowercase letters, digits and underscores');
114
+ export type SecretName = z.infer<typeof SecretName>;
115
+
116
+ /** Where a secret belongs. All four parts are the encryption's additional data, so a copied row will not decrypt. */
117
+ export const SecretRef = z.object({
118
+ installId: InstallId,
119
+ workspaceId: WorkspaceId,
120
+ appId: AppId,
121
+ name: SecretName,
122
+ });
123
+ export type SecretRef = z.infer<typeof SecretRef>;
124
+
125
+ /**
126
+ * The developer's OAuth client secret for their own service is stored once per app under this reserved install id,
127
+ * in the app owner's workspace: `{ installId: APP_CLIENT_INSTALL_ID, workspaceId: app.ownerWorkspaceId, appId, name: 'oauth_client_secret' }`.
128
+ * Uninstall never deletes it; only deleting the app does. Added in 0.3.1.
129
+ */
130
+ export const APP_CLIENT_INSTALL_ID = 'ins_appclient' as const;
131
+ export const APP_CLIENT_SECRET_NAME = 'oauth_client_secret' as const;
132
+ export function appClientSecretRef(appId: string, ownerWorkspaceId: string): SecretRef {
133
+ return { installId: APP_CLIENT_INSTALL_ID, workspaceId: ownerWorkspaceId, appId, name: APP_CLIENT_SECRET_NAME };
134
+ }
135
+
136
+ /** Engine-side code exchange for an app connecting OUT (the handoff at api.thesoogroup.com/oauth/callback). Added in 0.3.1. */
137
+ export const OAuthCodeExchange = z.object({
138
+ install: SecretRef.omit({ name: true }),
139
+ ownerWorkspaceId: WorkspaceId,
140
+ tokenUrl: z.string().url(),
141
+ clientId: z.string().min(1),
142
+ code: z.string().min(1),
143
+ codeVerifier: z.string().min(43).max(128).optional(),
144
+ redirectUri: z.string().url(),
145
+ });
146
+ export type OAuthCodeExchange = z.infer<typeof OAuthCodeExchange>;
147
+
148
+ /** What anyone outside the engine may see about a secret: never the value. */
149
+ export const SecretInfo = z.object({
150
+ name: SecretName,
151
+ keyVersion: z.string(),
152
+ expiresAt: Timestamp.nullable(),
153
+ updatedAt: Timestamp,
154
+ });
155
+ export type SecretInfo = z.infer<typeof SecretInfo>;
156
+
157
+ // ── RPC surfaces added in L1 (alongside RegistryRpc / EngineRpc) ─────────────
158
+
159
+ /** `backend-apps-registry` default WorkerEntrypoint implements this as well as `RegistryRpc`. */
160
+ export interface RegistryTrustRpc {
161
+ /** Create an app owned by the caller's workspace and mint its OAuth client (`loops create`). Developer only. */
162
+ createApp(caller: Caller, input: CreateAppInput): Promise<{ ok: true; app: AppRecord; client: MintedClient } | { ok: false; error: string }>;
163
+ /** Resolve a Loops OAuth access token (`Authorization: Bearer …`) for `/v1`; null when unknown, expired or revoked. */
164
+ resolveToken(bearer: string): Promise<TokenCaller | null>;
165
+ /** True when the user has the developer switch on in that workspace. */
166
+ isDeveloper(workspaceId: string, userId: string): Promise<boolean>;
167
+ /** Owner/Admin only. */
168
+ setDeveloper(caller: Caller, userId: string, on: boolean): Promise<{ ok: boolean; error?: string }>;
169
+ listDevelopers(caller: Caller): Promise<WorkspaceDeveloper[]>;
170
+ /** Consents for one install of the caller's workspace, newest first. */
171
+ listConsents(caller: Caller, installId: string): Promise<ConsentRecord[]>;
172
+ /** Audit rows for the caller's workspace (SUPER sees all), newest first. */
173
+ listAudit(caller: Caller, query: AuditQuery): Promise<{ items: AuditEntry[]; nextCursor: string | null }>;
174
+ }
175
+
176
+ /** `backend-apps-engine` default WorkerEntrypoint implements this as well as `EngineRpc`. Values go in, never out. */
177
+ export interface EngineVaultRpc {
178
+ putSecret(ref: SecretRef, value: string, expiresAt?: string | null): Promise<{ ok: true }>;
179
+ listSecrets(installId: string): Promise<SecretInfo[]>;
180
+ /** Uninstall calls this; returns how many rows went. */
181
+ deleteSecrets(installId: string): Promise<{ deleted: number }>;
182
+ /** Reads the app's client secret, POSTs to tokenUrl, stores access/refresh tokens for the install itself. Values never come back. Added in 0.3.1. */
183
+ exchangeOAuthCode(input: OAuthCodeExchange): Promise<{ ok: true; expiresAt: string | null } | { ok: false; error: string }>;
184
+ }