@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.
- package/README.md +3 -1
- package/SPEC.md +335 -31
- package/docs/architecture.md +125 -7
- package/docs/configuration.md +142 -21
- package/docs/hosted-dispatcher.md +17 -2
- package/docs/hosted-service.md +240 -0
- package/docs/mcp.md +8 -2
- package/docs/operator-api.md +97 -7
- package/docs/remote-store-protocol.md +199 -144
- package/docs/remote-ticket-protocol.md +249 -19
- package/docs/setup.md +199 -16
- package/docs/ticket-dependencies.md +1 -1
- package/docs/ticket-migration.md +64 -0
- package/package.json +7 -6
- package/packages/core/src/cli/attention.ts +209 -0
- package/packages/core/src/cli/build-control.ts +12 -1
- package/packages/core/src/cli/configured-store.ts +17 -0
- package/packages/core/src/cli/connect.ts +364 -0
- package/packages/core/src/cli/context.ts +7 -1
- package/packages/core/src/cli/dashboard/model.ts +4 -5
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch.ts +336 -41
- package/packages/core/src/cli/harvest.ts +13 -3
- package/packages/core/src/cli/help.ts +51 -16
- package/packages/core/src/cli/init-validation.ts +27 -2
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +60 -16
- package/packages/core/src/cli/mcp.ts +9 -2
- package/packages/core/src/cli/pr-summary-publication.ts +220 -0
- package/packages/core/src/cli/pr-summary.ts +2 -0
- package/packages/core/src/cli/project-credentials.ts +132 -0
- package/packages/core/src/cli/repo-state.ts +15 -2
- package/packages/core/src/cli/self-update.ts +15 -1
- package/packages/core/src/cli/store-opening.ts +22 -14
- package/packages/core/src/cli/terminals.ts +0 -10
- package/packages/core/src/cli/ticket-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +172 -35
- package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
- package/packages/core/src/cli/wait.ts +84 -17
- package/packages/core/src/cli/watch.ts +197 -60
- package/packages/core/src/config/live.ts +3 -0
- package/packages/core/src/config/schema.ts +90 -11
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +11 -2
- package/packages/core/src/events/payloads.ts +50 -0
- package/packages/core/src/events/repository.ts +13 -0
- package/packages/core/src/integration/harness.ts +37 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
- package/packages/core/src/kernel/engine.ts +24 -0
- package/packages/core/src/kernel/generators/build-log.ts +11 -1
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/log-index.ts +17 -0
- package/packages/core/src/kernel/pr-summary.ts +89 -0
- package/packages/core/src/kernel/reducer-registry.ts +10 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/ontology.ts +2 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/index.ts +12 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +38 -16
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/ticket-source-backend.ts +17 -20
- package/packages/core/src/operator/tickets.ts +136 -28
- package/packages/core/src/plugin-sdk/index.ts +7 -0
- package/packages/core/src/plugins/manifest.ts +5 -3
- package/packages/core/src/plugins/runtimes.ts +7 -1
- package/packages/core/src/ports/forge/fake.ts +13 -1
- package/packages/core/src/ports/forge/github-transport.ts +36 -1
- package/packages/core/src/ports/forge/github.ts +29 -5
- package/packages/core/src/ports/forge/local-git.ts +12 -4
- package/packages/core/src/ports/forge/summary-marker.ts +8 -0
- package/packages/core/src/ports/runner/session-env.ts +3 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +54 -10
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
- package/packages/core/src/ports/tickets/remote.ts +61 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +6 -1
- package/packages/core/src/ports/workspace/create.ts +15 -2
- package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
- package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
- package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
- package/packages/core/src/processes/build-execution-state.ts +1 -1
- package/packages/core/src/processes/build-runner.ts +87 -26
- package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
- package/packages/core/src/processes/dispatcher.ts +168 -41
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +31 -4
- package/packages/core/src/processes/harvest-execution-state.ts +19 -2
- package/packages/core/src/processes/harvest-runner.ts +166 -104
- package/packages/core/src/processes/harvest.ts +34 -14
- package/packages/core/src/processes/phase-failure-budget.ts +2 -0
- package/packages/core/src/processes/publication-loss.ts +18 -1
- package/packages/core/src/processes/repo-view.ts +35 -0
- package/packages/core/src/store/contract.ts +32 -1
- package/packages/core/src/store/local/store.ts +1 -1
- package/packages/core/src/store/remote/client.ts +1 -1
- package/packages/core/src/store/remote/compatibility.ts +90 -0
- package/packages/core/src/store/remote/credentials.ts +28 -0
- package/packages/core/src/store/remote/index.ts +14 -1
- package/packages/core/src/store/remote/protocol.ts +25 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +1 -1
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +232 -34
- package/skills/guide/references/operator-api.md +142 -13
- package/skills/guide/references/plugin-authoring.md +10 -2
- package/skills/guide/references/remote-store-protocol.md +199 -144
- package/skills/guide/references/remote-ticket-protocol.md +253 -18
- package/skills/guide/references/setup.md +199 -16
- package/skills/guide/references/ticket-dependencies.md +1 -1
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/operate/SKILL.md +126 -36
- package/skills/tickets/SKILL.md +71 -0
- package/templates/hosted-service/package.json +13 -0
- 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:
|
|
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 (
|
|
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
|
-
//
|
|
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 {
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
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).
|
|
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
|
+
}
|