@defrex/autobuild 0.11.0 → 0.13.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.
Files changed (136) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +335 -31
  3. package/docs/architecture.md +125 -7
  4. package/docs/configuration.md +142 -21
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +240 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +97 -7
  9. package/docs/remote-store-protocol.md +199 -144
  10. package/docs/remote-ticket-protocol.md +249 -19
  11. package/docs/setup.md +199 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/docs/ticket-migration.md +64 -0
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/build-control.ts +12 -1
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +364 -0
  19. package/packages/core/src/cli/context.ts +7 -1
  20. package/packages/core/src/cli/dashboard/model.ts +4 -5
  21. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  22. package/packages/core/src/cli/dispatch.ts +336 -41
  23. package/packages/core/src/cli/harvest.ts +13 -3
  24. package/packages/core/src/cli/help.ts +51 -16
  25. package/packages/core/src/cli/init-validation.ts +27 -2
  26. package/packages/core/src/cli/installation.ts +67 -13
  27. package/packages/core/src/cli/main.ts +60 -16
  28. package/packages/core/src/cli/mcp.ts +9 -2
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/self-update.ts +15 -1
  34. package/packages/core/src/cli/store-opening.ts +22 -14
  35. package/packages/core/src/cli/terminals.ts +0 -10
  36. package/packages/core/src/cli/ticket-assets.ts +7 -2
  37. package/packages/core/src/cli/ticket-editor.ts +82 -0
  38. package/packages/core/src/cli/ticket.ts +172 -35
  39. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  40. package/packages/core/src/cli/wait.ts +84 -17
  41. package/packages/core/src/cli/watch.ts +197 -60
  42. package/packages/core/src/config/live.ts +3 -0
  43. package/packages/core/src/config/schema.ts +90 -11
  44. package/packages/core/src/connection.ts +86 -0
  45. package/packages/core/src/events/catalog.ts +11 -2
  46. package/packages/core/src/events/payloads.ts +50 -0
  47. package/packages/core/src/events/repository.ts +13 -0
  48. package/packages/core/src/integration/harness.ts +37 -2
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
  50. package/packages/core/src/kernel/engine.ts +24 -0
  51. package/packages/core/src/kernel/generators/build-log.ts +11 -1
  52. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  53. package/packages/core/src/kernel/log-index.ts +17 -0
  54. package/packages/core/src/kernel/pr-summary.ts +89 -0
  55. package/packages/core/src/kernel/reducer-registry.ts +10 -0
  56. package/packages/core/src/kernel/reducer.ts +8 -0
  57. package/packages/core/src/ontology.ts +2 -0
  58. package/packages/core/src/operator/annotations.ts +12 -0
  59. package/packages/core/src/operator/index.ts +12 -0
  60. package/packages/core/src/operator/protocol.ts +19 -2
  61. package/packages/core/src/operator/query.ts +38 -16
  62. package/packages/core/src/operator/registry.ts +61 -8
  63. package/packages/core/src/operator/ticket-source-backend.ts +17 -20
  64. package/packages/core/src/operator/tickets.ts +136 -28
  65. package/packages/core/src/plugin-sdk/index.ts +7 -0
  66. package/packages/core/src/plugins/manifest.ts +5 -3
  67. package/packages/core/src/plugins/runtimes.ts +7 -1
  68. package/packages/core/src/ports/forge/fake.ts +13 -1
  69. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  70. package/packages/core/src/ports/forge/github.ts +29 -5
  71. package/packages/core/src/ports/forge/local-git.ts +12 -4
  72. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  73. package/packages/core/src/ports/runner/session-env.ts +3 -0
  74. package/packages/core/src/ports/tickets/contract.ts +66 -1
  75. package/packages/core/src/ports/tickets/create.ts +54 -10
  76. package/packages/core/src/ports/tickets/fake.ts +30 -1
  77. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  78. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  79. package/packages/core/src/ports/tickets/file.ts +114 -120
  80. package/packages/core/src/ports/tickets/grants.ts +47 -0
  81. package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
  82. package/packages/core/src/ports/tickets/identity.ts +20 -0
  83. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  84. package/packages/core/src/ports/tickets/linear.ts +74 -9
  85. package/packages/core/src/ports/tickets/migration.ts +336 -0
  86. package/packages/core/src/ports/tickets/operations.ts +38 -7
  87. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  88. package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
  89. package/packages/core/src/ports/tickets/remote.ts +61 -3
  90. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  91. package/packages/core/src/ports/tickets/revision.ts +62 -0
  92. package/packages/core/src/ports/tickets/states.ts +61 -0
  93. package/packages/core/src/ports/types.ts +40 -3
  94. package/packages/core/src/ports/workspace/build-execution.ts +6 -1
  95. package/packages/core/src/ports/workspace/create.ts +15 -2
  96. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  97. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  98. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  99. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  100. package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
  101. package/packages/core/src/processes/build-execution-state.ts +1 -1
  102. package/packages/core/src/processes/build-runner.ts +87 -26
  103. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  104. package/packages/core/src/processes/dispatcher.ts +168 -41
  105. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  106. package/packages/core/src/processes/harvest-child.ts +31 -4
  107. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  108. package/packages/core/src/processes/harvest-runner.ts +166 -104
  109. package/packages/core/src/processes/harvest.ts +34 -14
  110. package/packages/core/src/processes/phase-failure-budget.ts +2 -0
  111. package/packages/core/src/processes/publication-loss.ts +18 -1
  112. package/packages/core/src/processes/repo-view.ts +35 -0
  113. package/packages/core/src/store/contract.ts +32 -1
  114. package/packages/core/src/store/local/store.ts +1 -1
  115. package/packages/core/src/store/remote/client.ts +1 -1
  116. package/packages/core/src/store/remote/compatibility.ts +90 -0
  117. package/packages/core/src/store/remote/credentials.ts +28 -0
  118. package/packages/core/src/store/remote/index.ts +14 -1
  119. package/packages/core/src/store/remote/protocol.ts +25 -0
  120. package/packages/core/src/store/remote/token.ts +74 -13
  121. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  122. package/packages/core/src/store/ticket-assets.ts +86 -8
  123. package/packages/core/src/store/types.ts +1 -1
  124. package/packages/core/src/testing/index.ts +7 -0
  125. package/skills/guide/SKILL.md +232 -34
  126. package/skills/guide/references/operator-api.md +142 -13
  127. package/skills/guide/references/plugin-authoring.md +10 -2
  128. package/skills/guide/references/remote-store-protocol.md +199 -144
  129. package/skills/guide/references/remote-ticket-protocol.md +253 -18
  130. package/skills/guide/references/setup.md +199 -16
  131. package/skills/guide/references/ticket-dependencies.md +1 -1
  132. package/skills/guide/references/ticket-migration.md +64 -0
  133. package/skills/operate/SKILL.md +126 -36
  134. package/skills/tickets/SKILL.md +71 -0
  135. package/templates/hosted-service/package.json +13 -0
  136. package/templates/hosted-service/vercel.json +7 -0
@@ -1,3 +1,5 @@
1
+ import { prepareAssetSnapshots } from './ticket-asset-snapshots'
2
+ import { MemoryBuildStore } from './memory'
1
3
  /**
2
4
  * Reusable contract suites for the BuildStore seam (SPEC §7). The in-memory
3
5
  * adapter's behavior *is* the contract; every adapter — SQLite (§7.2.1),
@@ -2514,7 +2516,7 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
2514
2516
  async function pinnedBuild(
2515
2517
  store: BuildStore,
2516
2518
  slug: string,
2517
- assets: ReturnType<typeof pinOf>[],
2519
+ assets: import('../events/payloads').PinnedAsset[],
2518
2520
  ): Promise<void> {
2519
2521
  const created = buildCreatedWrite()
2520
2522
  await store.createBuild({
@@ -2523,6 +2525,35 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
2523
2525
  })
2524
2526
  }
2525
2527
 
2528
+ test('snapshot pins read independent bytes, historical revisions and scoped access', async () => {
2529
+ await withStore(factory, undefined, async (store) => {
2530
+ const source = new MemoryBuildStore()
2531
+ await source.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'remote one'))
2532
+ await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'local conflict'))
2533
+ const pins = await prepareAssetSnapshots(source, store, REPO, 'TICK-1')
2534
+ await pinnedBuild(store, 'snapshot-pin', pins)
2535
+ await source.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'remote two'))
2536
+ const newer = await prepareAssetSnapshots(source, store, REPO, 'TICK-1')
2537
+ await store.append('snapshot-pin', {
2538
+ actor: KERNEL,
2539
+ type: 'spec.revised',
2540
+ payload: { artifact: { kind: 'spec', rev: 1 }, escalation: 1, assets: newer },
2541
+ })
2542
+ await source.removeTicketAsset(REPO, 'TICK-1', 'design', 'a.txt')
2543
+ expect(text(await store.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt'))).toBe(
2544
+ 'remote two',
2545
+ )
2546
+ expect(text(await store.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt', 0))).toBe(
2547
+ 'remote one',
2548
+ )
2549
+ expect(await store.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt', 2)).toBeNull()
2550
+ const scoped = store.scopeBuild('snapshot-pin')
2551
+ expect(text(await scoped.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt'))).toBe(
2552
+ 'remote two',
2553
+ )
2554
+ })
2555
+ })
2556
+
2526
2557
  test('reads the pinned revision after the ticket replaces and then removes the asset', async () => {
2527
2558
  await withStore(factory, undefined, async (store) => {
2528
2559
  const v0 = await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'one'))
@@ -2036,7 +2036,7 @@ export class SqliteBuildStore implements BuildStore {
2036
2036
  return { stale: false, record: this.toStreamRecord(this.requireStream(streamId)) }
2037
2037
  },
2038
2038
  )
2039
- if (!committed.stale) return committed.record
2039
+ if (committed.stale === false) return committed.record
2040
2040
  // A foreign appender landed inside the prepare window; loop and
2041
2041
  // re-prepare from the newer chunk set so its acknowledged chunk is
2042
2042
  // not omitted from the finalized artifact.
@@ -578,7 +578,7 @@ export class RemoteBuildStore implements BuildStore {
578
578
 
579
579
  async getRepoHighWater(repo: string): Promise<number> {
580
580
  // Additive route beside `state-events`; the store service and client
581
- // ship in version lockstep, so no protocol bump.
581
+ // retain this additive route throughout the supported client window.
582
582
  return this.requestJson('GET', `${this.repoPath(repo)}/high-water`, repoHighWaterSchema).then(
583
583
  (body) => (body as { seq: number }).seq,
584
584
  )
@@ -0,0 +1,90 @@
1
+ import semver from 'semver'
2
+ import { z } from 'zod'
3
+ import { AUTOBUILD_VERSION, REMOTE_STORE_PROTOCOL_VERSION } from './version'
4
+
5
+ /** Stable releases only: prefixes, whitespace, prereleases and build suffixes are not identities. */
6
+ export function stableVersion(value: unknown): semver.SemVer | undefined {
7
+ if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/.test(value)) return undefined
8
+ const parsed = semver.parse(value)
9
+ return parsed && parsed.version === value ? parsed : undefined
10
+ }
11
+
12
+ const stableVersionSchema = z.string().refine((value) => stableVersion(value) !== undefined)
13
+ export const supportedClientRangeSchema = z
14
+ .strictObject({
15
+ minVersion: stableVersionSchema,
16
+ maxVersionExclusive: stableVersionSchema,
17
+ })
18
+ .refine((range) => semver.lt(range.minVersion, range.maxVersionExclusive))
19
+ export type SupportedClientRange = z.infer<typeof supportedClientRangeSchema>
20
+
21
+ /** A major starts a new family: X.0 has one line, X.1 two, X.2+ three. */
22
+ export function supportedClientRange(serviceVersion = AUTOBUILD_VERSION): SupportedClientRange {
23
+ const version = stableVersion(serviceVersion)
24
+ if (!version) throw new Error('service must have a stable release version')
25
+ return {
26
+ minVersion: `${version.major}.${Math.max(0, version.minor - 2)}.0`,
27
+ maxVersionExclusive: `${version.major}.${version.minor + 1}.0`,
28
+ }
29
+ }
30
+
31
+ export function classifyClient(
32
+ version: unknown,
33
+ range: SupportedClientRange,
34
+ ): 'supported' | 'older' | 'newer' | 'invalid' {
35
+ const parsed = stableVersion(version)
36
+ if (!parsed) return 'invalid'
37
+ if (semver.lt(parsed, range.minVersion)) return 'older'
38
+ if (semver.gte(parsed, range.maxVersionExclusive)) return 'newer'
39
+ return 'supported'
40
+ }
41
+
42
+ export function formatClientRange(range: SupportedClientRange): string {
43
+ return `>=${range.minVersion} <${range.maxVersionExclusive}`
44
+ }
45
+
46
+ export function compatibilityAdvice(
47
+ client: unknown,
48
+ serviceVersion: string,
49
+ range: SupportedClientRange,
50
+ ): string | undefined {
51
+ const classification = classifyClient(client, range)
52
+ const description = `service Autobuild ${serviceVersion} supports clients ${formatClientRange(range)}`
53
+ if (classification === 'supported') return undefined
54
+ if (classification === 'newer') {
55
+ const version = stableVersion(client)!
56
+ return `${description}; upgrade the service to at least ${version.major}.${version.minor}.0 before upgrading projects (service first)`
57
+ }
58
+ if (classification === 'older')
59
+ return `${description}; upgrade the project's Autobuild into ${formatClientRange(range)}`
60
+ return `${description}; supply a stable Autobuild client version`
61
+ }
62
+
63
+ /** Retain these wire identities and handlers until their lines leave the window.
64
+ * Historical lines require explicit entries; the service's own line uses its
65
+ * current protocol identity. Unknown historical protocols fail closed. */
66
+ export const RELEASE_LINE_PROTOCOLS: Readonly<Record<string, string>> = {
67
+ '0.9': '3',
68
+ '0.10': '3',
69
+ '0.11': '3',
70
+ '0.12': '3',
71
+ }
72
+
73
+ export function clientIdentityError(
74
+ client: string | null,
75
+ protocol: string | null,
76
+ serviceVersion = AUTOBUILD_VERSION,
77
+ serviceProtocol = REMOTE_STORE_PROTOCOL_VERSION,
78
+ ): string | undefined {
79
+ const range = supportedClientRange(serviceVersion)
80
+ const service = stableVersion(serviceVersion)!
81
+ const advice = compatibilityAdvice(client, serviceVersion, range)
82
+ const parsed = stableVersion(client)
83
+ const expected =
84
+ parsed &&
85
+ (parsed.major === service.major && parsed.minor === service.minor
86
+ ? serviceProtocol
87
+ : RELEASE_LINE_PROTOCOLS[`${parsed.major}.${parsed.minor}`])
88
+ if (!advice && expected !== undefined && protocol === expected) return undefined
89
+ return `remote store version mismatch: client Autobuild ${client ?? '(missing)'} protocol ${protocol ?? '(missing)'}; server Autobuild ${serviceVersion} protocol ${serviceProtocol}; ${advice ?? `supported clients ${formatClientRange(range)}; unsupported protocol identity`}`
90
+ }
@@ -0,0 +1,28 @@
1
+ import { delegateCredentialResponseSchema, type DelegateCredentialTarget } from './protocol'
2
+ import {
3
+ AUTOBUILD_VERSION,
4
+ AUTOBUILD_VERSION_HEADER,
5
+ REMOTE_STORE_PROTOCOL_VERSION,
6
+ REMOTE_STORE_PROTOCOL_VERSION_HEADER,
7
+ } from './version'
8
+
9
+ /** Fail closed: a remote execution must never fall back to its parent token. */
10
+ export async function delegateCredential(
11
+ storeRef: string,
12
+ token: string | undefined,
13
+ target: DelegateCredentialTarget,
14
+ ): Promise<string> {
15
+ if (!token) throw new Error('credential delegation requires parent authority')
16
+ const response = await fetch(`${storeRef.replace(/\/$/, '')}/credentials/delegate`, {
17
+ method: 'POST',
18
+ headers: {
19
+ authorization: `Bearer ${token}`,
20
+ 'content-type': 'application/json',
21
+ [AUTOBUILD_VERSION_HEADER]: AUTOBUILD_VERSION,
22
+ [REMOTE_STORE_PROTOCOL_VERSION_HEADER]: REMOTE_STORE_PROTOCOL_VERSION,
23
+ },
24
+ body: JSON.stringify(target),
25
+ })
26
+ if (!response.ok) throw new Error(`credential delegation refused (${response.status})`)
27
+ return delegateCredentialResponseSchema.parse(await response.json()).token
28
+ }
@@ -4,7 +4,13 @@
4
4
  export { AuthError, RemoteBuildStore } from './client'
5
5
  export type { RemoteBuildStoreOptions, RemoteStoreIdentity } from './client'
6
6
  export { mintToken, tokenResource, verifyToken } from './token'
7
- export type { OperatorTokenScope, TokenScope } from './token'
7
+ export type {
8
+ OperatorTokenScope,
9
+ InternalOperatorTokenScope,
10
+ AdminTokenScope,
11
+ TokenScope,
12
+ DelegatedTokenScope,
13
+ } from './token'
8
14
  export {
9
15
  AUTOBUILD_VERSION,
10
16
  AUTOBUILD_VERSION_HEADER,
@@ -22,3 +28,10 @@ export type { SessionEvent, SessionEventWrite } from '../../events/sessions'
22
28
  export type { RepositoryEvent, RepositoryEventWrite } from '../../events/repository'
23
29
  export { SNAPSHOT_REDUCER_NAME } from '../snapshots'
24
30
  export type { ReducerSnapshot, SnapshotScope } from '../snapshots'
31
+
32
+ export * from './compatibility'
33
+
34
+ export { delegateCredential } from './credentials'
35
+
36
+ export { reduceBuild } from '../../kernel/reducer'
37
+ export { reduceHarvest } from '../../kernel/harvest'
@@ -450,3 +450,28 @@ export function substitutePlaceholderRefs(
450
450
  }
451
451
  return value
452
452
  }
453
+
454
+ /** Issuance is an authentication operation, separate from the BuildStore port. */
455
+ export const delegateCredentialBodySchema = z.discriminatedUnion('kind', [
456
+ z.strictObject({ kind: z.literal('build-runner'), id: z.string().min(1) }),
457
+ z.strictObject({
458
+ kind: z.literal('build-phase'),
459
+ id: z.string().min(1),
460
+ session: z.string().min(1),
461
+ }),
462
+ z.strictObject({
463
+ kind: z.literal('harvest-runner'),
464
+ execution: z.string().min(1),
465
+ repo: z.string().min(1),
466
+ id: z.string().min(1),
467
+ leaseHolder: z.string().min(1),
468
+ }),
469
+ z.strictObject({
470
+ kind: z.literal('harvest-phase'),
471
+ repo: z.string().min(1),
472
+ id: z.string().min(1),
473
+ session: z.string().min(1),
474
+ }),
475
+ ])
476
+ export type DelegateCredentialTarget = z.infer<typeof delegateCredentialBodySchema>
477
+ export const delegateCredentialResponseSchema = z.strictObject({ token: z.string().min(1) })
@@ -1,13 +1,7 @@
1
- /**
2
- * HMAC-scoped store tokens (SPEC §8.1 [D8]): "the token is scoped to this
3
- * build and session — an agent physically cannot append to another build's
4
- * log or read another build's artifacts". Least privilege comes from the
5
- * runner minting a narrow token, not from prompt instructions.
6
- *
7
- * A token is `base64url(JSON scope) + '.' + base64url(hmac-sha256(secret,
8
- * payload))`. The scope IS the token — the server keeps no session state;
9
- * it verifies the signature (timing-safe) and the expiry, then trusts the
10
- * embedded scope.
1
+ /** Signed lifecycle-bound execution grants, internal owner delegation and admin.
2
+ * Historical resource/deployment/operator scopes remain decodable for tests
3
+ * and migration diagnostics; hosted machine gates reject those retired forms.
4
+ * Project credentials are opaque stored secrets, not these stateless tokens.
11
5
  */
12
6
  import { createHmac, timingSafeEqual } from 'node:crypto'
13
7
  import { z } from 'zod'
@@ -52,8 +46,35 @@ export interface DeploymentTokenScope {
52
46
  exp: number
53
47
  }
54
48
 
55
- /** Legacy scopes remain valid on their existing routes. */
49
+ /** Includes retired wire shapes for recognition, never server authorization. */
50
+ export interface InternalOperatorTokenScope {
51
+ version: 1
52
+ internalOperator: { user: string; repo: string }
53
+ exp: number
54
+ via?: Via
55
+ }
56
+ export interface AdminTokenScope {
57
+ version: 1
58
+ ownerAdmin: { user: string }
59
+ exp: number
60
+ }
61
+ export interface DelegatedTokenScope {
62
+ version: 2
63
+ delegation: {
64
+ repo: string
65
+ kind: 'build-runner' | 'build-phase' | 'harvest-runner' | 'harvest-phase'
66
+ id: string
67
+ anchor: number
68
+ session?: string
69
+ execution?: string
70
+ leaseHolder?: string
71
+ }
72
+ }
73
+
56
74
  export type TokenScope =
75
+ | DelegatedTokenScope
76
+ | InternalOperatorTokenScope
77
+ | AdminTokenScope
57
78
  | LegacyBuildTokenScope
58
79
  | ResourceTokenScope
59
80
  | OperatorTokenScope
@@ -63,7 +84,38 @@ const commonScope = {
63
84
  session: z.string().min(1),
64
85
  exp: z.number().int(),
65
86
  }
87
+ const exactIdentity = z
88
+ .string()
89
+ .min(1)
90
+ .refine((value) => value !== '*')
91
+ const delegatedClaims = z
92
+ .strictObject({
93
+ repo: exactIdentity,
94
+ kind: z.enum(['build-runner', 'build-phase', 'harvest-runner', 'harvest-phase']),
95
+ id: exactIdentity,
96
+ anchor: z.number().int().positive(),
97
+ session: exactIdentity.optional(),
98
+ execution: exactIdentity.optional(),
99
+ leaseHolder: exactIdentity.optional(),
100
+ })
101
+ .refine(
102
+ (claims) =>
103
+ claims.kind.endsWith('-phase') === (claims.session !== undefined) &&
104
+ claims.kind.startsWith('harvest') === (claims.execution !== undefined),
105
+ )
66
106
  const tokenScopeSchema = z.union([
107
+ z.strictObject({ version: z.literal(2), delegation: delegatedClaims }),
108
+ z.strictObject({
109
+ version: z.literal(1),
110
+ internalOperator: z.strictObject({ user: z.string().trim().min(1), repo: z.string().min(1) }),
111
+ exp: z.number().int(),
112
+ via: viaSchema.optional(),
113
+ }),
114
+ z.strictObject({
115
+ version: z.literal(1),
116
+ ownerAdmin: z.strictObject({ user: z.string().trim().min(1) }),
117
+ exp: z.number().int(),
118
+ }),
67
119
  z.strictObject({ build: z.string().min(1), ...commonScope }),
68
120
  z.strictObject({
69
121
  resource: z.strictObject({
@@ -85,6 +137,13 @@ export function tokenResource(scope: TokenScope): {
85
137
  kind: 'build' | 'repo' | 'session' | 'admin' | 'operator' | 'deployment'
86
138
  id: string
87
139
  } {
140
+ if ('delegation' in scope)
141
+ return {
142
+ kind: scope.delegation.kind.startsWith('build') ? 'build' : 'repo',
143
+ id: scope.delegation.kind.startsWith('build') ? scope.delegation.id : scope.delegation.repo,
144
+ }
145
+ if ('ownerAdmin' in scope) return { kind: 'admin', id: '*' }
146
+ if ('internalOperator' in scope) return { kind: 'operator', id: scope.internalOperator.user }
88
147
  if ('operator' in scope) {
89
148
  return scope.operator === true
90
149
  ? { kind: 'deployment', id: '*' }
@@ -108,7 +167,9 @@ export function mintToken(secret: string, scope: TokenScope): string {
108
167
  /**
109
168
  * Bad signature, malformed token, unparseable scope, or expired → null —
110
169
  * verification never explains itself to the caller (the server maps null to
111
- * 401). The signature compare is timing-safe via `crypto.timingSafeEqual`.
170
+ * 401). Version-2 grants deliberately have no fixed expiration: the service
171
+ * must additionally validate their registered project and durable lifecycle.
172
+ * The signature compare is timing-safe via `crypto.timingSafeEqual`.
112
173
  */
113
174
  export function verifyToken(secret: string, token: string, now: Date): TokenScope | null {
114
175
  const parts = token.split('.')
@@ -132,6 +193,6 @@ export function verifyToken(secret: string, token: string, now: Date): TokenScop
132
193
  }
133
194
  const scope = tokenScopeSchema.safeParse(raw)
134
195
  if (!scope.success) return null
135
- if (scope.data.exp <= now.getTime()) return null
196
+ if ('exp' in scope.data && scope.data.exp <= now.getTime()) return null
136
197
  return scope.data
137
198
  }
@@ -0,0 +1,192 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { gzipSync, gunzipSync } from 'node:zlib'
3
+ import { z } from 'zod'
4
+ import type { TicketAssetStore } from '../ports/types'
5
+ import type { PinnedAsset } from '../events/payloads'
6
+ import { contentHash, type BuildStore } from './types'
7
+ import {
8
+ pinnedAssetOf,
9
+ summarizeTicketAsset,
10
+ validateTicketAssetInput,
11
+ validateTicketAssetStructure,
12
+ type TicketAsset,
13
+ } from './ticket-assets'
14
+
15
+ const CHUNK_BYTES = 256 * 1024
16
+ const refSchema = z.strictObject({
17
+ kind: z.string().regex(/^ticket-snapshot-[a-f0-9-]+$/),
18
+ revision: z.number().int().nonnegative(),
19
+ })
20
+ const manifestSchema = z.strictObject({
21
+ version: z.literal(1),
22
+ meta: z.strictObject({
23
+ repo: z.string(),
24
+ ticketId: z.string(),
25
+ kind: z.string(),
26
+ name: z.string(),
27
+ revision: z.number().int().nonnegative(),
28
+ layout: z.enum(['file', 'tree']),
29
+ size: z.number().int().nonnegative(),
30
+ createdAt: z.string(),
31
+ entries: z.array(
32
+ z.discriminatedUnion('type', [
33
+ z.strictObject({ type: z.literal('dir'), path: z.string() }),
34
+ z.strictObject({
35
+ type: z.literal('file'),
36
+ path: z.string(),
37
+ size: z.number().int().nonnegative(),
38
+ blobRef: z.string(),
39
+ }),
40
+ ]),
41
+ ),
42
+ }),
43
+ files: z.array(
44
+ z.strictObject({
45
+ path: z.string(),
46
+ chunks: z.array(
47
+ refSchema.extend({ hash: z.string(), size: z.number().int().min(1).max(CHUNK_BYTES) }),
48
+ ),
49
+ }),
50
+ ),
51
+ })
52
+
53
+ function verifyMetadata(
54
+ meta: TicketAsset['meta'],
55
+ repo: string,
56
+ ticketId: string,
57
+ pin: PinnedAsset,
58
+ ): void {
59
+ const summary = pinnedAssetOf(summarizeTicketAsset(meta))
60
+ if (
61
+ meta.repo !== repo ||
62
+ meta.ticketId !== ticketId ||
63
+ Object.entries(summary).some(([key, value]) => pin[key as keyof typeof summary] !== value) ||
64
+ validateTicketAssetStructure(meta) !== meta.size
65
+ )
66
+ throw new Error('inconsistent ticket asset snapshot metadata')
67
+ }
68
+
69
+ function verify(asset: TicketAsset, repo: string, ticketId: string, pin: PinnedAsset): void {
70
+ const { meta, entries } = asset
71
+ verifyMetadata(meta, repo, ticketId, pin)
72
+ if (
73
+ validateTicketAssetInput({ ...meta, entries }) !== meta.size ||
74
+ entries.length !== meta.entries.length
75
+ ) {
76
+ throw new Error('inconsistent ticket asset snapshot metadata')
77
+ }
78
+ for (let i = 0; i < entries.length; i++) {
79
+ const entry = entries[i]!
80
+ const stored = meta.entries[i]!
81
+ if (
82
+ entry.path !== stored.path ||
83
+ entry.type !== stored.type ||
84
+ (entry.type === 'file' &&
85
+ stored.type === 'file' &&
86
+ (entry.content.length !== stored.size || contentHash(entry.content) !== stored.blobRef))
87
+ ) {
88
+ throw new Error('inconsistent ticket asset snapshot content')
89
+ }
90
+ }
91
+ }
92
+
93
+ /** Copy exact sampled revisions before publishing any pins. No current ticket rows
94
+ * are written to the destination; their revision sequences remain independent. */
95
+ export async function prepareAssetSnapshots(
96
+ source: Pick<TicketAssetStore, 'listTicketAssets' | 'getTicketAsset'>,
97
+ store: Pick<BuildStore, 'putRepoArtifact' | 'ensureRepo'>,
98
+ repo: string,
99
+ ticketId: string,
100
+ canonicalTicketId: string = ticketId,
101
+ ): Promise<PinnedAsset[]> {
102
+ const pins = (await source.listTicketAssets(repo, ticketId)).map(pinnedAssetOf)
103
+ if (pins.length > 0) await store.ensureRepo(repo)
104
+ for (const pin of pins) {
105
+ const asset = await source.getTicketAsset(repo, ticketId, pin.kind, pin.name, pin.revision)
106
+ if (!asset) throw new Error('sampled ticket asset revision is missing')
107
+ manifestSchema.shape.meta.parse(asset.meta)
108
+ verify(asset, repo, canonicalTicketId, pin)
109
+ const files = []
110
+ for (const entry of asset.entries) {
111
+ if (entry.type !== 'file') continue
112
+ const chunks = []
113
+ for (let start = 0; start < entry.content.length; start += CHUNK_BYTES) {
114
+ const bytes = entry.content.slice(start, start + CHUNK_BYTES)
115
+ const meta = await store.putRepoArtifact(repo, {
116
+ kind: `ticket-snapshot-${randomUUID()}`,
117
+ content: bytes,
118
+ })
119
+ chunks.push({
120
+ kind: meta.kind,
121
+ revision: meta.revision,
122
+ size: bytes.length,
123
+ hash: contentHash(bytes),
124
+ })
125
+ }
126
+ files.push({ path: entry.path, chunks })
127
+ }
128
+ const meta = await store.putRepoArtifact(repo, {
129
+ kind: `ticket-snapshot-${randomUUID()}`,
130
+ // Compression also bounds manifests with many maximum-length escaped paths.
131
+ content: gzipSync(JSON.stringify({ version: 1, meta: { ...asset.meta, ticketId }, files })),
132
+ })
133
+ pin.snapshot = { kind: meta.kind, revision: meta.revision }
134
+ }
135
+ return pins
136
+ }
137
+
138
+ export async function readAssetSnapshot(
139
+ store: Pick<BuildStore, 'getRepoArtifact'>,
140
+ repo: string,
141
+ ticketId: string,
142
+ pin: PinnedAsset,
143
+ historicalTicketId?: string,
144
+ ): Promise<TicketAsset> {
145
+ if (!pin.snapshot) throw new Error('missing snapshot reference')
146
+ const artifact = await store.getRepoArtifact(repo, pin.snapshot.kind, pin.snapshot.revision)
147
+ if (!artifact) throw new Error('ticket asset snapshot manifest is missing')
148
+ const manifest = manifestSchema.parse(
149
+ JSON.parse(gunzipSync(artifact.content, { maxOutputLength: 4 * 1024 * 1024 }).toString('utf8')),
150
+ )
151
+ // BuildStore projections can display a readable ID while the immutable
152
+ // build.created event and its older snapshots retain the storage alias.
153
+ const snapshotTicketId =
154
+ manifest.meta.ticketId === historicalTicketId ? historicalTicketId : ticketId
155
+ verifyMetadata(manifest.meta, repo, snapshotTicketId, pin)
156
+ const entries: TicketAsset['entries'] = []
157
+ const files = new Map(manifest.files.map((file) => [file.path, file]))
158
+ if (
159
+ files.size !== manifest.files.length ||
160
+ files.size !== manifest.meta.entries.filter((entry) => entry.type === 'file').length
161
+ ) {
162
+ throw new Error('inconsistent ticket asset snapshot files')
163
+ }
164
+ for (const entry of manifest.meta.entries) {
165
+ if (entry.type === 'dir') {
166
+ entries.push(entry)
167
+ continue
168
+ }
169
+ const file = files.get(entry.path)
170
+ if (!file || file.chunks.reduce((size, chunk) => size + chunk.size, 0) !== entry.size) {
171
+ throw new Error('inconsistent ticket asset snapshot chunks')
172
+ }
173
+ const content = new Uint8Array(entry.size)
174
+ let offset = 0
175
+ for (const chunk of file.chunks) {
176
+ const artifact = await store.getRepoArtifact(repo, chunk.kind, chunk.revision)
177
+ if (
178
+ !artifact ||
179
+ artifact.content.length !== chunk.size ||
180
+ contentHash(artifact.content) !== chunk.hash
181
+ ) {
182
+ throw new Error('ticket asset snapshot chunk is missing or corrupt')
183
+ }
184
+ content.set(artifact.content, offset)
185
+ offset += chunk.size
186
+ }
187
+ entries.push({ type: 'file', path: entry.path, content })
188
+ }
189
+ const asset = { meta: manifest.meta, entries }
190
+ verify(asset, repo, snapshotTicketId, pin)
191
+ return asset
192
+ }