@fabrika/control-contract 0.2.0 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/control-contract",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,7 +24,9 @@
24
24
  "typecheck": "tsc --noEmit"
25
25
  },
26
26
  "dependencies": {
27
- "@fabrika/app": "^0.2.0",
28
- "@fabrika/provider-contract": "^0.2.0"
27
+ "zod": "^3.25.76",
28
+ "@fabrika/auth-core": "^0.3.2",
29
+ "@fabrika/app": "^0.3.2",
30
+ "@fabrika/provider-contract": "^0.3.2"
29
31
  }
30
32
  }
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod'
2
+
3
+ export const BUILDER_APPROVAL_STORAGE_KEY = 'fabrika.builder.approval.v1'
4
+ export const BUILDER_AUTHORIZE_PATH = '/builder/authorize'
5
+
6
+ export interface BuilderApprovalState {
7
+ readonly attemptId: string
8
+ readonly approvalVerifier: string
9
+ readonly expiresAt: number
10
+ }
11
+
12
+ export interface BuilderInstallationDiscoveryDto {
13
+ readonly version: 1
14
+ readonly controlOrigin: string
15
+ readonly iamIssuer: string
16
+ readonly provider: string
17
+ }
18
+
19
+ /** Self-contained so the public capture page can embed this exact validator without external assets. */
20
+ export function parseBuilderApprovalState(value: unknown, nowSeconds = Math.floor(Date.now() / 1000)): BuilderApprovalState | null {
21
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
22
+ if (Object.keys(value).length !== 3 || Object.keys(value).some((key) => !['attemptId', 'approvalVerifier', 'expiresAt'].includes(key))) return null
23
+ const attemptId: unknown = Reflect.get(value, 'attemptId')
24
+ const approvalVerifier: unknown = Reflect.get(value, 'approvalVerifier')
25
+ const expiresAt: unknown = Reflect.get(value, 'expiresAt')
26
+ if (typeof attemptId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(attemptId)) return null
27
+ if (typeof approvalVerifier !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(approvalVerifier)) return null
28
+ if (typeof expiresAt !== 'number' || !Number.isSafeInteger(expiresAt) || expiresAt <= nowSeconds || expiresAt > nowSeconds + 600) return null
29
+ return { attemptId, approvalVerifier, expiresAt }
30
+ }
31
+
32
+ /** Accept only a credential-free installation origin, never a redirect or a repository-selected path. */
33
+ export function parseBuilderPublicOrigin(value: unknown): string | null {
34
+ if (typeof value !== 'string' || value.length > 2048) return null
35
+ try {
36
+ const url = new URL(value)
37
+ const local = url.hostname === 'localhost' || url.hostname.endsWith('.localhost') || url.hostname === '[::1]'
38
+ || /^127(?:\.[0-9]{1,3}){3}$/.test(url.hostname)
39
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return null
40
+ if (url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '') return null
41
+ return url.origin
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ export function parseBuilderInstallationDiscovery(value: unknown): BuilderInstallationDiscoveryDto | null {
48
+ const result = builderInstallationDiscoverySchema.safeParse(value)
49
+ return result.success ? result.data : null
50
+ }
51
+
52
+ export const builderPublicOriginSchema = z.string().transform((value, ctx) => {
53
+ const origin = parseBuilderPublicOrigin(value)
54
+ if (origin !== null) return origin
55
+ ctx.addIssue({ code: 'custom', message: 'invalid builder public origin' })
56
+ return z.NEVER
57
+ })
58
+
59
+ export const builderInstallationDiscoverySchema = z.preprocess(
60
+ (value) => {
61
+ // Discovery requires four own fields, even when a prototype supplies otherwise valid values.
62
+ if (typeof value === 'object' && value !== null && Object.keys(value).length !== 4) return undefined
63
+ return value
64
+ },
65
+ z.object({
66
+ version: z.literal(1),
67
+ controlOrigin: builderPublicOriginSchema,
68
+ iamIssuer: builderPublicOriginSchema,
69
+ provider: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
70
+ }).strict(),
71
+ )
@@ -0,0 +1,86 @@
1
+ import { z } from 'zod'
2
+ import type { AppDto } from './index.js'
3
+
4
+ export const BUILDER_CONNECTION_ATTEMPT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
5
+ export const BUILDER_APPROVAL_VERIFIER = /^[A-Za-z0-9_-]{43}$/
6
+ export const BUILDER_CONNECTION_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
7
+
8
+ export interface BuilderConnectionEnvironmentDto {
9
+ readonly env: string
10
+ readonly namespaceId: string | null
11
+ readonly domain: string | null
12
+ readonly publicOrigin: string | null
13
+ readonly triggerRef: string | null
14
+ }
15
+
16
+ export interface BuilderConnectionReceiptDto {
17
+ readonly version: 1
18
+ readonly attemptId: string
19
+ readonly provider: string
20
+ readonly app: AppDto
21
+ readonly environment: BuilderConnectionEnvironmentDto
22
+ readonly createdAt: number
23
+ }
24
+
25
+ export interface CreateBuilderConnectionReceiptRequest {
26
+ readonly attemptId: string
27
+ readonly approvalVerifier: string
28
+ readonly appId: string
29
+ readonly environment: BuilderConnectionEnvironmentDto
30
+ }
31
+
32
+ export interface GetBuilderConnectionReceiptRequest {
33
+ readonly attemptId: string
34
+ }
35
+
36
+ /** Decode the persisted/wire snapshot without accepting provider envelopes or credential fields. */
37
+ export function parseBuilderConnectionReceipt(value: unknown): BuilderConnectionReceiptDto {
38
+ const result = builderConnectionReceiptSchema.safeParse(value)
39
+ if (result.success) return result.data
40
+ const issues = result.error.issues
41
+ const objectIssue = (path: string) =>
42
+ issues.some((issue) => issue.path.join('.') === path && (issue.code === 'invalid_type' || issue.code === 'unrecognized_keys'))
43
+ if (objectIssue('')) throw new Error('invalid builder connection receipt object')
44
+ if (issues.some((issue) => issue.path[0] === 'version')) throw new Error('unsupported builder connection receipt version')
45
+ if (objectIssue('app') || objectIssue('environment')) throw new Error('invalid builder connection receipt object')
46
+ if (issues.some((issue) => issue.path.join('.') === 'app.githubInstallationId')) throw new Error('invalid githubInstallationId')
47
+ const issue = issues[0]
48
+ throw new Error(`invalid receipt ${issue?.path.at(-1) ?? 'object'}`)
49
+ }
50
+
51
+ const receiptTimestampSchema = z.number().int().safe().nonnegative()
52
+ const receiptIdentifierSchema = z.string().regex(BUILDER_CONNECTION_IDENTIFIER)
53
+
54
+ export const builderConnectionAppSchema = z.object({
55
+ id: receiptIdentifierSchema,
56
+ repoUrl: z.string(),
57
+ defaultBranch: z.string(),
58
+ workerDir: z.string().nullable(),
59
+ buildCmd: z.string().nullable(),
60
+ configPath: z.string().nullable(),
61
+ githubConnectionId: z.string().nullable().optional(),
62
+ githubInstallationId: receiptTimestampSchema.nullable(),
63
+ createdAt: receiptTimestampSchema,
64
+ }).strict().transform(({ githubConnectionId, githubInstallationId, createdAt, ...app }) => ({
65
+ ...app,
66
+ ...(githubConnectionId === undefined ? {} : { githubConnectionId }),
67
+ githubInstallationId,
68
+ createdAt,
69
+ }))
70
+
71
+ export const builderConnectionEnvironmentSchema = z.object({
72
+ env: receiptIdentifierSchema,
73
+ namespaceId: z.string().min(1).max(128).nullable(),
74
+ domain: z.string().min(1).max(253).nullable(),
75
+ publicOrigin: z.string().min(1).max(2048).nullable(),
76
+ triggerRef: z.string().min(1).max(1024).nullable(),
77
+ }).strict()
78
+
79
+ export const builderConnectionReceiptSchema = z.object({
80
+ version: z.literal(1),
81
+ attemptId: z.string().regex(BUILDER_CONNECTION_ATTEMPT_ID),
82
+ provider: receiptIdentifierSchema,
83
+ app: builderConnectionAppSchema,
84
+ environment: builderConnectionEnvironmentSchema,
85
+ createdAt: receiptTimestampSchema,
86
+ }).strict()
@@ -0,0 +1,24 @@
1
+ export type {
2
+ ApproveDeviceAuthorizationResult,
3
+ DenyDeviceAuthorizationResult,
4
+ DeviceAuthorizationFailure,
5
+ DeviceAuthorizationReceipt,
6
+ DeviceAuthorizationRequest,
7
+ InspectDeviceAuthorizationResult,
8
+ ListOwnDeviceAuthorizationsResult,
9
+ RevokeKeyResult,
10
+ } from '@fabrika/auth-core'
11
+
12
+ export interface BuilderPairingProofRequest {
13
+ readonly attemptId: string
14
+ readonly approvalVerifier: string
15
+ }
16
+
17
+ export interface ListOwnBuilderPairingsRequest {
18
+ readonly before?: string
19
+ readonly limit?: number
20
+ }
21
+
22
+ export interface RevokeBuilderPairingRequest {
23
+ readonly credentialId: string
24
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import type { JsonValue, ProviderEnvelope } from '@fabrika/provider-contract'
2
+ import { z } from 'zod'
3
+
4
+ export * from './builder-bootstrap.js'
5
+ export * from './builder-connections.js'
6
+ export * from './builder-pairing.js'
2
7
 
3
8
  export type { JsonValue }
4
9
 
@@ -15,6 +20,12 @@ export interface CursorList<T> {
15
20
 
16
21
  export type ProviderEnvelopeDto = ProviderEnvelope
17
22
 
23
+ /** What the console needs to know about the installation it runs in. */
24
+ export interface InstallationDto {
25
+ /** The id of the one provider bundle this installation composes (ADR-0011). */
26
+ readonly provider: string
27
+ }
28
+
18
29
  export interface GitHubSourceRepositoryDto {
19
30
  readonly owner: string
20
31
  readonly name: string
@@ -79,9 +90,7 @@ export interface GitHubSourceConnectionConnectedDto extends GitHubSourceConnecti
79
90
 
80
91
  export type GitHubSourceConnectionWorkflowDto = Exclude<GitHubSourceConnectionStatusDto, GitHubSourceConnectionConnectedDto>
81
92
 
82
- export const GITHUB_SOURCE_CONNECTION_DEFAULT_PAGE_SIZE = 50
83
- export const GITHUB_SOURCE_CONNECTION_MAX_PAGE_SIZE = 100
84
- export const GITHUB_SOURCE_CONNECTION_PAGE_CURSOR_MAX_LENGTH = 512
93
+ export * from './source-connection-list.js'
85
94
 
86
95
  export interface GitHubSourceConnectionListInput {
87
96
  readonly cursor?: string
@@ -95,32 +104,6 @@ export interface GitHubSourceConnectionListResponse {
95
104
  readonly workflow: GitHubSourceConnectionWorkflowDto | null
96
105
  }
97
106
 
98
- export function decodeGitHubSourceConnectionListInput(value: unknown): GitHubSourceConnectionListInput {
99
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
100
- throw new Error('source connection list input must be an object')
101
- }
102
- const unknownField = Object.keys(value).find((key) => key !== 'cursor' && key !== 'limit')
103
- if (unknownField !== undefined) throw new Error('source connection list input contains an unknown field')
104
- const cursorValue = Reflect.get(value, 'cursor')
105
- if (
106
- cursorValue !== undefined
107
- && (typeof cursorValue !== 'string' || cursorValue.length === 0 || cursorValue.length > GITHUB_SOURCE_CONNECTION_PAGE_CURSOR_MAX_LENGTH)
108
- ) {
109
- throw new Error(`source connection list cursor must be a non-empty string of at most ${GITHUB_SOURCE_CONNECTION_PAGE_CURSOR_MAX_LENGTH} characters`)
110
- }
111
- const limitValue = Reflect.get(value, 'limit')
112
- if (
113
- limitValue !== undefined
114
- && (typeof limitValue !== 'number' || !Number.isSafeInteger(limitValue) || limitValue < 1 || limitValue > GITHUB_SOURCE_CONNECTION_MAX_PAGE_SIZE)
115
- ) {
116
- throw new Error(`source connection list limit must be an integer from 1 to ${GITHUB_SOURCE_CONNECTION_MAX_PAGE_SIZE}`)
117
- }
118
- return {
119
- ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
120
- ...(limitValue !== undefined ? { limit: limitValue } : {}),
121
- }
122
- }
123
-
124
107
  export interface GitHubSourceConnectionRepairRequiredDto extends GitHubSourceConnectionBaseDto {
125
108
  readonly state: 'repair-required'
126
109
  readonly connectionId: string
@@ -150,12 +133,14 @@ export interface StartGitHubSourceConnectionResponse {
150
133
  readonly continuePath: string
151
134
  }
152
135
 
153
- /** One redacted line of deploy output exposed by the control API. */
154
- export interface RunLogLine {
155
- readonly ts: number
156
- readonly stream: 'stdout' | 'stderr' | 'meta'
157
- readonly text: string
158
- }
136
+ /** One redacted line of deploy output: the runner's NDJSON log line and the control API's view of it. */
137
+ export const runLogLineSchema = z.object({
138
+ ts: z.number().finite(),
139
+ stream: z.enum(['stdout', 'stderr', 'meta']),
140
+ text: z.string(),
141
+ })
142
+
143
+ export type RunLogLine = z.infer<typeof runLogLineSchema>
159
144
 
160
145
  /** Backward-compatible short name for browser consumers. */
161
146
  export type LogLine = RunLogLine
@@ -182,7 +167,9 @@ export interface AppOptionalFields {
182
167
  workerDir?: string | null
183
168
  buildCmd?: string | null
184
169
  configPath?: string | null
170
+ /** Must equal the installation of the repository owner's GitHub connection. */
185
171
  githubInstallationId?: number | null
172
+ /** Bind the app to the repository owner's GitHub connection (one per owner). */
186
173
  resolveInstallationId?: boolean
187
174
  }
188
175
 
@@ -212,22 +199,6 @@ export interface PutAppEnvRequest {
212
199
  artifact: ProviderEnvelopeDto
213
200
  }
214
201
 
215
- export interface CloudflareTargetEnvelope extends ProviderEnvelopeDto {
216
- provider: 'cloudflare'
217
- version: 1
218
- payload: {
219
- stateNamespace?: string
220
- }
221
- }
222
-
223
- export interface CloudflareArtifactEnvelope extends ProviderEnvelopeDto {
224
- provider: 'cloudflare'
225
- version: 1
226
- payload: {
227
- configPath: string
228
- }
229
- }
230
-
231
202
  export type DeploymentNamespaceState = 'pending' | 'provisioning' | 'ready' | 'failed'
232
203
 
233
204
  export interface DeploymentNamespaceDto {
@@ -341,17 +312,26 @@ export interface PlanDeploymentNamespaceResponse {
341
312
  readonly presentation: DeploymentNamespacePresentationDto
342
313
  }
343
314
 
315
+ /**
316
+ * Where an app secret's value is kept: `vault` (control's vault), `provider` (the provider's own storage),
317
+ * or `legacy` — a row from before secrets were vault-only, which no deploy resolves until it is set again.
318
+ */
319
+ export type AppSecretStorage = 'vault' | 'provider' | 'legacy'
320
+
344
321
  export interface AppSecretDto {
345
322
  appId: string
346
323
  env: string | null
347
324
  name: string
348
- valueRef: string
325
+ storage: AppSecretStorage
326
+ /** The `vault:` ref; null for any other storage, whose stored ref control cannot vouch for. */
327
+ valueRef: string | null
349
328
  createdAt: number
350
329
  }
351
330
 
331
+ /** The plaintext is write-only: control seals it and stores only the resulting ref. */
352
332
  export interface PutAppSecretRequest {
353
333
  name: string
354
- valueRef: string
334
+ value: string
355
335
  env?: string | null
356
336
  }
357
337
 
package/src/rpc.ts CHANGED
@@ -1,4 +1,15 @@
1
1
  import type { RpcProcedureContract } from '@fabrika/app'
2
+ import type { BuilderConnectionReceiptDto, CreateBuilderConnectionReceiptRequest, GetBuilderConnectionReceiptRequest } from './builder-connections.js'
3
+ import type {
4
+ ApproveDeviceAuthorizationResult,
5
+ BuilderPairingProofRequest,
6
+ DenyDeviceAuthorizationResult,
7
+ InspectDeviceAuthorizationResult,
8
+ ListOwnBuilderPairingsRequest,
9
+ ListOwnDeviceAuthorizationsResult,
10
+ RevokeBuilderPairingRequest,
11
+ RevokeKeyResult,
12
+ } from './builder-pairing.js'
2
13
  import type {
3
14
  AdoptDeploymentNamespaceRequest,
4
15
  AppDto,
@@ -12,6 +23,7 @@ import type {
12
23
  GitHubSourceConnectionListInput,
13
24
  GitHubSourceConnectionListResponse,
14
25
  GitHubSourceConnectionStatusDto,
26
+ InstallationDto,
15
27
  ListResponse,
16
28
  PlanDeploymentNamespaceRequest,
17
29
  PlanDeploymentNamespaceResponse,
@@ -24,7 +36,6 @@ import type {
24
36
  RunDto,
25
37
  RunLogResponse,
26
38
  RunTailResponse,
27
- SetSecretValueRequest,
28
39
  StartGitHubSourceConnectionRequest,
29
40
  StartGitHubSourceConnectionResponse,
30
41
  TriggerDeployRequest,
@@ -105,7 +116,6 @@ export interface GitHubSourceConnectionInput {
105
116
 
106
117
  /** Provider-neutral admin seam. A statically composed provider implements these GitHub operations. */
107
118
  export interface GitHubSourceConnectionRpcContract {
108
- status: RpcProcedure<void, GitHubSourceConnectionStatusDto>
109
119
  list: RpcProcedure<GitHubSourceConnectionListInput, GitHubSourceConnectionListResponse>
110
120
  start: RpcProcedure<StartGitHubSourceConnectionRequest, StartGitHubSourceConnectionResponse>
111
121
  verifyInstallation: RpcProcedure<GitHubSourceConnectionInput, GitHubSourceConnectionStatusDto>
@@ -113,8 +123,20 @@ export interface GitHubSourceConnectionRpcContract {
113
123
  reconcile: RpcProcedure<GitHubSourceConnectionInput, GitHubSourceConnectionStatusDto>
114
124
  }
115
125
 
116
- /** Portable Delivery control API, implemented alongside the backward-compatible REST transport. */
126
+ /** Portable Delivery control API shared by browser and machine callers. */
117
127
  export interface ControlRpcContract {
128
+ installation: RpcProcedure<void, InstallationDto>
129
+ builderPairing: {
130
+ inspect: RpcProcedure<BuilderPairingProofRequest, InspectDeviceAuthorizationResult>
131
+ approve: RpcProcedure<BuilderPairingProofRequest, ApproveDeviceAuthorizationResult>
132
+ deny: RpcProcedure<BuilderPairingProofRequest, DenyDeviceAuthorizationResult>
133
+ listOwn: RpcProcedure<ListOwnBuilderPairingsRequest, ListOwnDeviceAuthorizationsResult>
134
+ revoke: RpcProcedure<RevokeBuilderPairingRequest, RevokeKeyResult>
135
+ }
136
+ builderConnections: {
137
+ createReceipt: RpcProcedure<CreateBuilderConnectionReceiptRequest, BuilderConnectionReceiptDto>
138
+ getReceipt: RpcProcedure<GetBuilderConnectionReceiptRequest, BuilderConnectionReceiptDto>
139
+ }
118
140
  sourceConnection: GitHubSourceConnectionRpcContract
119
141
  apps: {
120
142
  list: RpcProcedure<void, ListResponse<AppDto>>
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod'
2
+
3
+ export const GITHUB_SOURCE_CONNECTION_DEFAULT_PAGE_SIZE = 50
4
+ export const GITHUB_SOURCE_CONNECTION_MAX_PAGE_SIZE = 100
5
+ export const GITHUB_SOURCE_CONNECTION_PAGE_CURSOR_MAX_LENGTH = 512
6
+
7
+ export const githubSourceConnectionListInputSchema = z.object({
8
+ cursor: z.string().min(1).max(GITHUB_SOURCE_CONNECTION_PAGE_CURSOR_MAX_LENGTH).optional(),
9
+ limit: z.number().int().safe().min(1).max(GITHUB_SOURCE_CONNECTION_MAX_PAGE_SIZE).optional(),
10
+ }).strict().transform(({ cursor, limit }) => ({
11
+ ...(cursor === undefined ? {} : { cursor }),
12
+ ...(limit === undefined ? {} : { limit }),
13
+ }))