@fabrika/operations-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/operations-contract",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,6 +59,7 @@
59
59
  "typecheck": "tsc --noEmit"
60
60
  },
61
61
  "dependencies": {
62
- "@fabrika/app": "^0.2.0"
62
+ "zod": "^3.25.76",
63
+ "@fabrika/app": "^0.3.2"
63
64
  }
64
65
  }
package/src/catalog.ts CHANGED
@@ -1,73 +1,59 @@
1
- /** Versioned private Control → Operations source-catalog protocol. */
1
+ /** Private Control → Operations source-catalog protocol; both services ship together. */
2
2
 
3
- export const OPERATIONS_CATALOG_PROTOCOL_VERSION = 2
4
3
  export const DEFAULT_OPERATIONS_SERVICE_KEY = 'default'
5
4
 
6
- export interface OperationsSourceCoordinateV1 {
5
+ export interface OperationsSourceCoordinate {
7
6
  appId: string
8
7
  environment: string
9
8
  /** Omitted by Control while an app environment has only its default service. */
10
9
  serviceKey?: string
11
10
  }
12
11
 
13
- export interface CanonicalOperationsSourceCoordinateV1 {
12
+ export interface CanonicalOperationsSourceCoordinate {
14
13
  appId: string
15
14
  environment: string
16
15
  serviceKey: string
17
16
  }
18
17
 
19
- export interface OperationsCatalogSourceV1 {
20
- coordinate: OperationsSourceCoordinateV1
21
- displayName: string
22
- /** Canonical externally reachable origin when Control owns one; null when it does not. */
23
- publicOrigin?: string | null
24
- }
25
-
26
- export interface CanonicalOperationsCatalogSourceV1 {
27
- coordinate: CanonicalOperationsSourceCoordinateV1
28
- displayName: string
29
- publicOrigin: string | null
30
- }
31
-
32
18
  /** Caller-owned write credential transported only over the private catalog channel. */
33
- export interface OperationsCatalogIngestCredentialV2 {
19
+ export interface OperationsCatalogIngestCredential {
34
20
  id: string
35
21
  publicKey: string
36
22
  }
37
23
 
38
- export interface OperationsCatalogSourceV2 extends OperationsCatalogSourceV1 {
39
- ingestCredential: OperationsCatalogIngestCredentialV2
24
+ export interface OperationsCatalogSource {
25
+ coordinate: OperationsSourceCoordinate
26
+ displayName: string
27
+ /** Canonical externally reachable origin when Control owns one; null when it does not. */
28
+ publicOrigin?: string | null
29
+ ingestCredential: OperationsCatalogIngestCredential
40
30
  }
41
31
 
42
- export interface CanonicalOperationsCatalogSourceV2 extends CanonicalOperationsCatalogSourceV1 {
43
- ingestCredential: OperationsCatalogIngestCredentialV2
32
+ export interface CanonicalOperationsCatalogSource {
33
+ coordinate: CanonicalOperationsSourceCoordinate
34
+ displayName: string
35
+ publicOrigin: string | null
36
+ ingestCredential: OperationsCatalogIngestCredential
44
37
  }
45
38
 
46
- export interface OperationsCatalogReconcileRequestV2 {
47
- protocolVersion: typeof OPERATIONS_CATALOG_PROTOCOL_VERSION
39
+ export interface OperationsCatalogReconcileRequest {
48
40
  revision: number
49
41
  snapshotHash: string
50
- sources: OperationsCatalogSourceV2[]
42
+ sources: OperationsCatalogSource[]
51
43
  }
52
44
 
53
45
  export type OperationsCatalogReconcileOutcome = 'applied' | 'unchanged' | 'stale'
54
46
 
55
- export interface OperationsCatalogIngestResultV2 {
56
- coordinate: CanonicalOperationsSourceCoordinateV1
47
+ export interface OperationsCatalogIngestResult {
48
+ coordinate: CanonicalOperationsSourceCoordinate
57
49
  credentialId: string
58
50
  ingestProjectId: string
59
51
  }
60
52
 
61
- export interface OperationsCatalogReconcileResponseV2 {
62
- protocolVersion: typeof OPERATIONS_CATALOG_PROTOCOL_VERSION
53
+ export interface OperationsCatalogReconcileResponse {
63
54
  revision: number
64
55
  outcome: OperationsCatalogReconcileOutcome
65
- created: number
66
- updated: number
67
- disabled: number
68
- reenabled: number
69
- unchanged: number
70
- ingest: OperationsCatalogIngestResultV2[]
56
+ ingest: OperationsCatalogIngestResult[]
71
57
  }
72
58
 
73
59
  export function canonicalOperationsServiceKey(serviceKey: string | undefined): string {
@@ -75,8 +61,8 @@ export function canonicalOperationsServiceKey(serviceKey: string | undefined): s
75
61
  }
76
62
 
77
63
  export function canonicalOperationsCatalogSources(
78
- sources: readonly OperationsCatalogSourceV2[],
79
- ): CanonicalOperationsCatalogSourceV2[] {
64
+ sources: readonly OperationsCatalogSource[],
65
+ ): CanonicalOperationsCatalogSource[] {
80
66
  return sources.map((source) => ({
81
67
  coordinate: {
82
68
  appId: source.coordinate.appId,
@@ -96,7 +82,7 @@ export function canonicalOperationsCatalogSources(
96
82
  }
97
83
 
98
84
  /** Hash the canonical source array only; revision is transport ordering, not catalog content. */
99
- export async function operationsCatalogSnapshotHash(sources: readonly OperationsCatalogSourceV2[]): Promise<string> {
85
+ export async function operationsCatalogSnapshotHash(sources: readonly OperationsCatalogSource[]): Promise<string> {
100
86
  const bytes = new TextEncoder().encode(JSON.stringify(canonicalOperationsCatalogSources(sources)))
101
87
  const digest = await crypto.subtle.digest('SHA-256', bytes)
102
88
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
package/src/index.ts CHANGED
@@ -4,21 +4,18 @@ export {
4
4
  canonicalOperationsCatalogSources,
5
5
  canonicalOperationsServiceKey,
6
6
  DEFAULT_OPERATIONS_SERVICE_KEY,
7
- OPERATIONS_CATALOG_PROTOCOL_VERSION,
8
7
  operationsCatalogSnapshotHash,
9
8
  } from './catalog.js'
10
9
  export type {
11
- CanonicalOperationsCatalogSourceV1,
12
- CanonicalOperationsCatalogSourceV2,
13
- CanonicalOperationsSourceCoordinateV1,
14
- OperationsCatalogIngestCredentialV2,
15
- OperationsCatalogIngestResultV2,
10
+ CanonicalOperationsCatalogSource,
11
+ CanonicalOperationsSourceCoordinate,
12
+ OperationsCatalogIngestCredential,
13
+ OperationsCatalogIngestResult,
16
14
  OperationsCatalogReconcileOutcome,
17
- OperationsCatalogReconcileRequestV2,
18
- OperationsCatalogReconcileResponseV2,
19
- OperationsCatalogSourceV1,
20
- OperationsCatalogSourceV2,
21
- OperationsSourceCoordinateV1,
15
+ OperationsCatalogReconcileRequest,
16
+ OperationsCatalogReconcileResponse,
17
+ OperationsCatalogSource,
18
+ OperationsSourceCoordinate,
22
19
  } from './catalog.js'
23
20
  export {
24
21
  buildOperationsDsn,
@@ -98,7 +95,6 @@ export {
98
95
  FABRIKA_RELEASE,
99
96
  normalizeOperationsCommit,
100
97
  OPERATIONS_ARTIFACT_HEADERS,
101
- OPERATIONS_RELEASE_PROTOCOL_VERSION,
102
98
  OPERATIONS_RELEASE_RECONCILE_PATH,
103
99
  OPERATIONS_SOURCE_MAP_UPLOAD_PATH,
104
100
  operationsReleaseName,
@@ -107,14 +103,14 @@ export {
107
103
  export type {
108
104
  OperationsArtifactState,
109
105
  OperationsArtifactUploadConfiguration,
110
- OperationsArtifactUploadCredentialV1,
111
- OperationsReleaseAvailabilityV1,
112
- OperationsReleaseCoordinateV1,
106
+ OperationsArtifactUploadCredential,
107
+ OperationsReleaseAvailability,
108
+ OperationsReleaseCoordinate,
113
109
  OperationsReleaseOutcome,
114
110
  OperationsReleasePhase,
115
111
  OperationsReleaseReconcileOutcome,
116
- OperationsReleaseReconcileRequestV1,
117
- OperationsReleaseReconcileResponseV1,
112
+ OperationsReleaseReconcileRequest,
113
+ OperationsReleaseReconcileResponse,
118
114
  OperationsReleaseUnavailableReason,
119
115
  } from './releases.js'
120
116
  export type { OperationsIssueQuery, OperationsReleaseQuery, OperationsRpcContract } from './rpc.js'
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod'
2
+
3
+ export const operationsReleaseCoordinateSchema = z.object({
4
+ appId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
5
+ environment: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
6
+ serviceKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,62}$/).optional(),
7
+ }).transform(({ appId, environment, serviceKey }) => ({ appId, environment, ...(serviceKey === undefined ? {} : { serviceKey }) }))
8
+
9
+ export const operationsReleaseAvailabilitySchema = z.discriminatedUnion('kind', [
10
+ z.object({
11
+ kind: z.literal('available'),
12
+ name: z.string().max(512),
13
+ commitSha: z.string().toLowerCase().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/),
14
+ }),
15
+ z.object({ kind: z.literal('unavailable'), reason: z.enum(['dry_run', 'missing_commit']) }),
16
+ ])
17
+
18
+ export const operationsArtifactUploadCredentialSchema = z.object({
19
+ verifier: z.string().regex(/^[0-9a-f]{64}$/),
20
+ expiresAt: z.number().int().safe(),
21
+ })
22
+
23
+ export const operationsReleasePayloadSchema = z.object({
24
+ runId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/),
25
+ coordinate: operationsReleaseCoordinateSchema,
26
+ phase: z.enum(['started', 'provider_accepted', 'terminal']),
27
+ providerRunId: z.string().max(256).nullable(),
28
+ outcome: z.enum(['succeeded', 'failed']).nullable(),
29
+ artifactState: z.enum(['pending', 'complete', 'incomplete', 'not_applicable']),
30
+ release: operationsReleaseAvailabilitySchema,
31
+ uploadCredential: operationsArtifactUploadCredentialSchema.optional(),
32
+ observedAt: z.number().int().safe().nonnegative(),
33
+ }).superRefine((value, ctx) => {
34
+ if (value.uploadCredential !== undefined && value.uploadCredential.expiresAt <= value.observedAt) {
35
+ ctx.addIssue({ code: 'custom', path: ['uploadCredential'], message: 'invalid upload credential' })
36
+ }
37
+ if (value.release.kind === 'unavailable' && value.uploadCredential !== undefined) {
38
+ ctx.addIssue({ code: 'custom', message: 'unavailable releases cannot accept artifacts' })
39
+ }
40
+ if (value.phase === 'terminal' && value.outcome === null) {
41
+ ctx.addIssue({ code: 'custom', message: 'terminal release has no outcome' })
42
+ }
43
+ if (value.phase !== 'terminal' && value.outcome !== null) {
44
+ ctx.addIssue({ code: 'custom', message: 'non-terminal release has an outcome' })
45
+ }
46
+ }).transform(({ uploadCredential, observedAt, ...payload }) => ({
47
+ ...payload,
48
+ ...(uploadCredential === undefined ? {} : { uploadCredential }),
49
+ observedAt,
50
+ }))
51
+
52
+ const releaseRequestHeaderSchema = z.object({ revision: z.number().int().safe().min(1) }).passthrough()
53
+
54
+ export const operationsReleaseRequestSchema = releaseRequestHeaderSchema.transform((value, ctx) => {
55
+ const payload = operationsReleasePayloadSchema.safeParse(value)
56
+ if (!payload.success) {
57
+ for (const issue of payload.error.issues) ctx.addIssue(issue)
58
+ return z.NEVER
59
+ }
60
+ return { revision: value.revision, ...payload.data }
61
+ })
62
+
63
+ /** Preserve the public decoder's error categories and first-failure precedence. */
64
+ export function operationsReleaseIssueMessage(issues: z.ZodIssue[]): string {
65
+ if (
66
+ issues.some((issue) => {
67
+ const field = issue.path[0]
68
+ return issue.code !== 'custom' && (field !== 'coordinate' && field !== 'release' && field !== 'uploadCredential'
69
+ || ((field === 'coordinate' || field === 'release') && issue.path.length === 1 && issue.code === 'invalid_type'))
70
+ })
71
+ ) return 'invalid release request'
72
+ if (issues.some((issue) => issue.path[0] === 'coordinate')) return 'invalid release coordinates'
73
+ const release = issues.find((issue) => issue.path[0] === 'release')
74
+ if (release !== undefined) {
75
+ if (release.code === 'invalid_union_discriminator') return 'invalid release availability'
76
+ return release.path[1] === 'reason' ? 'invalid unavailable release' : 'invalid available release'
77
+ }
78
+ if (issues.some((issue) => issue.path[0] === 'uploadCredential')) return 'invalid upload credential'
79
+ return issues[0]?.message ?? 'invalid release request'
80
+ }
package/src/releases.ts CHANGED
@@ -1,8 +1,16 @@
1
- /** Versioned private Delivery → Operations release and artifact protocol. */
1
+ /** Private Delivery → Operations release and artifact protocol; both services ship together. */
2
2
 
3
3
  import { DEFAULT_OPERATIONS_SERVICE_KEY } from './catalog.js'
4
+ import { operationsReleaseIssueMessage, operationsReleasePayloadSchema, operationsReleaseRequestSchema } from './release-schemas.js'
5
+
6
+ export {
7
+ operationsArtifactUploadCredentialSchema,
8
+ operationsReleaseAvailabilitySchema,
9
+ operationsReleaseCoordinateSchema,
10
+ operationsReleasePayloadSchema,
11
+ operationsReleaseRequestSchema,
12
+ } from './release-schemas.js'
4
13
 
5
- export const OPERATIONS_RELEASE_PROTOCOL_VERSION = 1
6
14
  export const FABRIKA_RELEASE = 'FABRIKA_RELEASE'
7
15
  export const OPERATIONS_RELEASE_RECONCILE_PATH = '/private/releases/reconcile'
8
16
  export const OPERATIONS_SOURCE_MAP_UPLOAD_PATH = '/api/artifacts/source-maps/'
@@ -22,13 +30,13 @@ export type OperationsReleaseOutcome = 'succeeded' | 'failed' | null
22
30
  export type OperationsArtifactState = 'pending' | 'complete' | 'incomplete' | 'not_applicable'
23
31
  export type OperationsReleaseUnavailableReason = 'dry_run' | 'missing_commit'
24
32
 
25
- export interface OperationsReleaseCoordinateV1 {
33
+ export interface OperationsReleaseCoordinate {
26
34
  appId: string
27
35
  environment: string
28
36
  serviceKey?: string
29
37
  }
30
38
 
31
- export type OperationsReleaseAvailabilityV1 =
39
+ export type OperationsReleaseAvailability =
32
40
  | {
33
41
  kind: 'available'
34
42
  name: string
@@ -39,33 +47,46 @@ export type OperationsReleaseAvailabilityV1 =
39
47
  reason: OperationsReleaseUnavailableReason
40
48
  }
41
49
 
42
- export interface OperationsArtifactUploadCredentialV1 {
50
+ export interface OperationsArtifactUploadCredential {
43
51
  /** SHA-256 verifier. The bearer itself never crosses the private projection boundary. */
44
52
  verifier: string
45
53
  expiresAt: number
46
54
  }
47
55
 
48
- export interface OperationsReleaseReconcileRequestV1 {
49
- protocolVersion: typeof OPERATIONS_RELEASE_PROTOCOL_VERSION
56
+ export interface OperationsReleaseReconcileRequest {
50
57
  revision: number
51
58
  runId: string
52
- coordinate: OperationsReleaseCoordinateV1
59
+ coordinate: OperationsReleaseCoordinate
53
60
  phase: OperationsReleasePhase
54
61
  providerRunId: string | null
55
62
  outcome: OperationsReleaseOutcome
56
63
  artifactState: OperationsArtifactState
57
- release: OperationsReleaseAvailabilityV1
58
- uploadCredential?: OperationsArtifactUploadCredentialV1
64
+ release: OperationsReleaseAvailability
65
+ uploadCredential?: OperationsArtifactUploadCredential
59
66
  observedAt: number
60
67
  }
61
68
 
69
+ export type OperationsReleaseProjectionPayload = Omit<OperationsReleaseReconcileRequest, 'revision'>
70
+
71
+ export class OperationsReleaseDecodeError extends Error {}
72
+
73
+ export function parseOperationsReleaseRequest(value: unknown): OperationsReleaseReconcileRequest {
74
+ const result = operationsReleaseRequestSchema.safeParse(value)
75
+ if (!result.success) throw new OperationsReleaseDecodeError(operationsReleaseIssueMessage(result.error.issues))
76
+ return result.data
77
+ }
78
+
79
+ export function parseOperationsReleasePayload(value: unknown): OperationsReleaseProjectionPayload {
80
+ const result = operationsReleasePayloadSchema.safeParse(value)
81
+ if (!result.success) throw new OperationsReleaseDecodeError(operationsReleaseIssueMessage(result.error.issues))
82
+ return result.data
83
+ }
84
+
62
85
  export type OperationsReleaseReconcileOutcome = 'applied' | 'unchanged' | 'stale'
63
86
 
64
- export interface OperationsReleaseReconcileResponseV1 {
65
- protocolVersion: typeof OPERATIONS_RELEASE_PROTOCOL_VERSION
87
+ export interface OperationsReleaseReconcileResponse {
66
88
  revision: number
67
89
  outcome: OperationsReleaseReconcileOutcome
68
- releaseId: string | null
69
90
  }
70
91
 
71
92
  export interface OperationsArtifactUploadConfiguration {