@mannyc1/ts-release 0.2.2 → 0.3.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 (107) hide show
  1. package/ARCHITECTURE.md +60 -0
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +129 -6
  4. package/SPEC.md +51 -0
  5. package/apps/release-ts/README.md +7 -0
  6. package/apps/ts-release-agents/package.json +1 -1
  7. package/apps/ts-release-agents/src/check.ts +7 -2
  8. package/apps/ts-release-agents/test/contract.test.ts +2 -2
  9. package/dist/bin/ts-release.js +154 -47
  10. package/dist/drivers/archive.d.ts +2 -1
  11. package/dist/drivers/archive.d.ts.map +1 -1
  12. package/dist/drivers/archive.js +12 -8
  13. package/dist/drivers/archive.js.map +1 -1
  14. package/dist/drivers/process.d.ts.map +1 -1
  15. package/dist/drivers/process.js +4 -4
  16. package/dist/drivers/process.js.map +1 -1
  17. package/dist/drivers/python-wheel.d.ts +21 -0
  18. package/dist/drivers/python-wheel.d.ts.map +1 -0
  19. package/dist/drivers/python-wheel.js +109 -0
  20. package/dist/drivers/python-wheel.js.map +1 -0
  21. package/dist/drivers/workspace.d.ts +3 -0
  22. package/dist/drivers/workspace.d.ts.map +1 -1
  23. package/dist/drivers/workspace.js +5 -1
  24. package/dist/drivers/workspace.js.map +1 -1
  25. package/dist/operation-journal/authority.d.ts +3 -0
  26. package/dist/operation-journal/authority.d.ts.map +1 -0
  27. package/dist/operation-journal/authority.js +147 -0
  28. package/dist/operation-journal/authority.js.map +1 -0
  29. package/dist/operation-journal/aws/deadline.d.ts +12 -0
  30. package/dist/operation-journal/aws/deadline.d.ts.map +1 -0
  31. package/dist/operation-journal/aws/deadline.js +38 -0
  32. package/dist/operation-journal/aws/deadline.js.map +1 -0
  33. package/dist/operation-journal/aws/oidc.d.ts +27 -0
  34. package/dist/operation-journal/aws/oidc.d.ts.map +1 -0
  35. package/dist/operation-journal/aws/oidc.js +361 -0
  36. package/dist/operation-journal/aws/oidc.js.map +1 -0
  37. package/dist/operation-journal/aws/policy.d.ts +42 -0
  38. package/dist/operation-journal/aws/policy.d.ts.map +1 -0
  39. package/dist/operation-journal/aws/policy.js +404 -0
  40. package/dist/operation-journal/aws/policy.js.map +1 -0
  41. package/dist/operation-journal/aws/s3-boundary.d.ts +17 -0
  42. package/dist/operation-journal/aws/s3-boundary.d.ts.map +1 -0
  43. package/dist/operation-journal/aws/s3-boundary.js +417 -0
  44. package/dist/operation-journal/aws/s3-boundary.js.map +1 -0
  45. package/dist/operation-journal/aws.d.ts +11 -0
  46. package/dist/operation-journal/aws.d.ts.map +1 -0
  47. package/dist/operation-journal/aws.js +50 -0
  48. package/dist/operation-journal/aws.js.map +1 -0
  49. package/dist/operation-journal/canonical.d.ts +52 -0
  50. package/dist/operation-journal/canonical.d.ts.map +1 -0
  51. package/dist/operation-journal/canonical.js +282 -0
  52. package/dist/operation-journal/canonical.js.map +1 -0
  53. package/dist/operation-journal/model.d.ts +251 -0
  54. package/dist/operation-journal/model.d.ts.map +1 -0
  55. package/dist/operation-journal/model.js +56 -0
  56. package/dist/operation-journal/model.js.map +1 -0
  57. package/dist/operation-journal/reducer.d.ts +28 -0
  58. package/dist/operation-journal/reducer.d.ts.map +1 -0
  59. package/dist/operation-journal/reducer.js +73 -0
  60. package/dist/operation-journal/reducer.js.map +1 -0
  61. package/dist/operation-journal/s3.d.ts +7 -0
  62. package/dist/operation-journal/s3.d.ts.map +1 -0
  63. package/dist/operation-journal/s3.js +667 -0
  64. package/dist/operation-journal/s3.js.map +1 -0
  65. package/dist/operation-journal.d.ts +14 -0
  66. package/dist/operation-journal.d.ts.map +1 -0
  67. package/dist/operation-journal.js +12 -0
  68. package/dist/operation-journal.js.map +1 -0
  69. package/dist/platform/credentials.d.ts.map +1 -1
  70. package/dist/platform/credentials.js +65 -3
  71. package/dist/platform/credentials.js.map +1 -1
  72. package/dist/publication/github.d.ts.map +1 -1
  73. package/dist/publication/github.js +66 -15
  74. package/dist/publication/github.js.map +1 -1
  75. package/dist/release/capabilities.d.ts.map +1 -1
  76. package/dist/release/capabilities.js +2 -2
  77. package/dist/release/capabilities.js.map +1 -1
  78. package/dist/release/prepare.d.ts.map +1 -1
  79. package/dist/release/prepare.js +4 -5
  80. package/dist/release/prepare.js.map +1 -1
  81. package/dist/release/prepared-store.d.ts.map +1 -1
  82. package/dist/release/prepared-store.js +11 -9
  83. package/dist/release/prepared-store.js.map +1 -1
  84. package/package.json +19 -4
  85. package/src/drivers/archive.ts +17 -8
  86. package/src/drivers/process.ts +3 -4
  87. package/src/drivers/python-wheel.ts +145 -0
  88. package/src/drivers/workspace.ts +7 -1
  89. package/src/operation-journal/authority.ts +162 -0
  90. package/src/operation-journal/aws/deadline.ts +41 -0
  91. package/src/operation-journal/aws/oidc.ts +439 -0
  92. package/src/operation-journal/aws/policy.ts +491 -0
  93. package/src/operation-journal/aws/s3-boundary.ts +562 -0
  94. package/src/operation-journal/aws.ts +60 -0
  95. package/src/operation-journal/canonical.ts +373 -0
  96. package/src/operation-journal/model.ts +301 -0
  97. package/src/operation-journal/reducer.ts +112 -0
  98. package/src/operation-journal/s3.ts +884 -0
  99. package/src/operation-journal.ts +51 -0
  100. package/src/platform/credentials.ts +71 -3
  101. package/src/publication/github.ts +83 -15
  102. package/src/release/capabilities.ts +3 -2
  103. package/src/release/prepare.ts +3 -5
  104. package/src/release/prepared-store.ts +8 -7
  105. package/templates/README.md +2 -2
  106. package/templates/github-actions/release.yml +24 -7
  107. package/templates/github-actions/reviewed-release.yml +49 -14
@@ -0,0 +1,562 @@
1
+ import { Buffer } from "node:buffer"
2
+ import {
3
+ GetRoleCommand,
4
+ GetRolePolicyCommand,
5
+ IAMClient,
6
+ ListAttachedRolePoliciesCommand,
7
+ ListRolePoliciesCommand
8
+ } from "@aws-sdk/client-iam"
9
+ import {
10
+ GetBucketOwnershipControlsCommand,
11
+ GetBucketPolicyCommand,
12
+ GetBucketPolicyStatusCommand,
13
+ GetBucketVersioningCommand,
14
+ GetObjectAttributesCommand,
15
+ GetObjectCommand,
16
+ GetObjectLockConfigurationCommand,
17
+ GetObjectRetentionCommand,
18
+ GetPublicAccessBlockCommand,
19
+ ListObjectVersionsCommand,
20
+ PutObjectCommand,
21
+ S3Client
22
+ } from "@aws-sdk/client-s3"
23
+ import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"
24
+ import * as Effect from "effect/Effect"
25
+ import { isCanonicalS3VersionId, objectChecksum } from "../canonical.js"
26
+ import {
27
+ S3JournalBoundaryError,
28
+ operationJournalByteLimits,
29
+ type S3JournalAuthority,
30
+ type S3JournalBoundaryShape,
31
+ type S3JournalNamespaceListing,
32
+ type S3JournalObjectVersion,
33
+ type S3JournalPutRequest,
34
+ type S3JournalPutResult
35
+ } from "../model.js"
36
+ import type { AwsJournalSession } from "./oidc.js"
37
+ import {
38
+ operationJournalNetworkDeadlineMilliseconds,
39
+ withOperationJournalNetworkDeadline
40
+ } from "./deadline.js"
41
+ import {
42
+ assertAwsJournalPolicyDigests,
43
+ validateAwsJournalPolicies,
44
+ type AwsJournalPolicyCoordinates
45
+ } from "./policy.js"
46
+
47
+ export interface AwsS3JournalBoundaryOptions {
48
+ readonly authority: S3JournalAuthority
49
+ readonly rolePolicyName: string
50
+ }
51
+
52
+ export interface AwsJournalClients {
53
+ readonly iam: IAMClient
54
+ readonly s3: S3Client
55
+ readonly sts: STSClient
56
+ }
57
+
58
+ const error = (
59
+ operation: S3JournalBoundaryError["operation"],
60
+ commitment: S3JournalBoundaryError["commitment"],
61
+ reason: string
62
+ ): S3JournalBoundaryError => S3JournalBoundaryError.make({ operation, commitment, reason })
63
+
64
+ const fail = (
65
+ operation: S3JournalBoundaryError["operation"],
66
+ reason: string
67
+ ): never => {
68
+ throw error(operation, "not-applicable", reason)
69
+ }
70
+
71
+ const boundaryError = (
72
+ cause: unknown,
73
+ operation: S3JournalBoundaryError["operation"],
74
+ reason: string
75
+ ): S3JournalBoundaryError => cause instanceof S3JournalBoundaryError
76
+ ? cause
77
+ : error(operation, "not-applicable", reason)
78
+
79
+ const statusCode = (cause: unknown): number | undefined => {
80
+ if (typeof cause !== "object" || cause === null || !("$metadata" in cause)) return undefined
81
+ const metadata = (cause as { readonly $metadata?: { readonly httpStatusCode?: unknown } }).$metadata
82
+ return typeof metadata?.httpStatusCode === "number" ? metadata.httpStatusCode : undefined
83
+ }
84
+
85
+ const errorName = (cause: unknown): string => {
86
+ if (typeof cause !== "object" || cause === null || !("name" in cause) ||
87
+ typeof (cause as { readonly name?: unknown }).name !== "string") return "UnknownError"
88
+ const name = (cause as { readonly name: string }).name
89
+ return /^[A-Za-z0-9_.-]{1,128}$/u.test(name) ? name : "UnknownError"
90
+ }
91
+
92
+ const putFailure = (cause: unknown, operation: "put-event" | "put-head"): S3JournalBoundaryError => {
93
+ if (cause instanceof S3JournalBoundaryError) return cause
94
+ const status = statusCode(cause)
95
+ return error(
96
+ operation,
97
+ status !== undefined && status >= 300 && status < 500 ? "not-committed" : "unknown",
98
+ `AWS S3 conditional write did not return one admitted response (${errorName(cause)}).`
99
+ )
100
+ }
101
+
102
+ const roleName = (roleArn: string): string => roleArn.slice(roleArn.lastIndexOf("/") + 1)
103
+
104
+ const journalNamespacePattern = /^operation-journal\/v1\/[a-f0-9]{40}\/[a-f0-9]{64}\/$/u
105
+ const journalEventKeyPattern = /^operation-journal\/v1\/[a-f0-9]{40}\/[a-f0-9]{64}\/events\/[0-9]{8}\/[a-f0-9-]{36}\.bin$/u
106
+ const journalHeadKeyPattern = /^operation-journal\/v1\/[a-f0-9]{40}\/[a-f0-9]{64}\/head\.bin$/u
107
+
108
+ const requireSealedInput = (
109
+ input: { readonly bucketName: string, readonly expectedBucketOwner: string, readonly region: string },
110
+ authority: S3JournalAuthority,
111
+ operation: S3JournalBoundaryError["operation"]
112
+ ): void => {
113
+ if (input.bucketName !== authority.bucketName ||
114
+ input.expectedBucketOwner !== authority.expectedBucketOwner ||
115
+ input.region !== authority.region) {
116
+ fail(operation, "AWS journal request coordinates escaped the sealed activation contract.")
117
+ }
118
+ }
119
+
120
+ const requireString = (
121
+ value: string | undefined,
122
+ operation: S3JournalBoundaryError["operation"],
123
+ label: string
124
+ ): string => {
125
+ if (value === undefined || value.length === 0) fail(operation, `${label} is absent.`)
126
+ return value as string
127
+ }
128
+
129
+ const checksumToBase64 = (checksum: string, operation: "put-event" | "put-head"): string => {
130
+ if (!/^sha256:[a-f0-9]{64}$/u.test(checksum)) fail(operation, "Journal checksum is not canonical SHA-256.")
131
+ return Buffer.from(checksum.slice("sha256:".length), "hex").toString("base64")
132
+ }
133
+
134
+ const checksumFromBase64 = (
135
+ checksum: string | undefined,
136
+ operation: S3JournalBoundaryError["operation"]
137
+ ): string => {
138
+ if (checksum === undefined || !/^(?:[A-Za-z0-9+/]{4}){10}[A-Za-z0-9+/]{3}=$/u.test(checksum)) {
139
+ fail(operation, "AWS S3 response lacks one canonical SHA-256 checksum.")
140
+ }
141
+ const canonicalChecksum = checksum as string
142
+ const bytes = Buffer.from(canonicalChecksum, "base64")
143
+ if (bytes.length !== 32 || bytes.toString("base64") !== canonicalChecksum) {
144
+ fail(operation, "AWS S3 response SHA-256 checksum is malformed.")
145
+ }
146
+ return `sha256:${bytes.toString("hex")}`
147
+ }
148
+
149
+ export const readBoundedObjectBody = async (
150
+ body: unknown,
151
+ contentLength: number,
152
+ deadlineMilliseconds = operationJournalNetworkDeadlineMilliseconds
153
+ ): Promise<Uint8Array> => {
154
+ if (!Number.isSafeInteger(contentLength) || contentLength < 1 ||
155
+ contentLength > operationJournalByteLimits.object) {
156
+ fail("get-object-version", "AWS S3 exact object Content-Length exceeds the admitted byte bound.")
157
+ }
158
+ if (typeof body !== "object" || body === null ||
159
+ typeof (body as { readonly [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] !== "function") {
160
+ fail("get-object-version", "AWS S3 exact object body is not one bounded byte stream.")
161
+ }
162
+ const stream = body as AsyncIterable<unknown>
163
+ const iterator = stream[Symbol.asyncIterator]()
164
+ return withOperationJournalNetworkDeadline(async (signal) => {
165
+ const stop = (): void => {
166
+ try {
167
+ const destroy = (body as { readonly destroy?: (cause?: unknown) => void }).destroy
168
+ if (typeof destroy === "function") destroy.call(body, signal.reason)
169
+ } catch {
170
+ // The deadline result is authoritative; cleanup failures are not a fallback result.
171
+ }
172
+ try {
173
+ const completion = iterator.return?.()
174
+ if (completion !== undefined) void Promise.resolve(completion).catch(() => undefined)
175
+ } catch {
176
+ // The deadline result is authoritative; cleanup failures are not a fallback result.
177
+ }
178
+ }
179
+ signal.addEventListener("abort", stop, { once: true })
180
+ const bytes = new Uint8Array(contentLength)
181
+ let offset = 0
182
+ try {
183
+ while (true) {
184
+ const result = await iterator.next()
185
+ if (result.done === true) break
186
+ const byteChunk = result.value instanceof Uint8Array
187
+ ? result.value
188
+ : fail("get-object-version", "AWS S3 exact object stream emitted a non-byte chunk.")
189
+ if (offset + byteChunk.length > bytes.length) {
190
+ fail("get-object-version", "AWS S3 exact object stream exceeded its admitted Content-Length.")
191
+ }
192
+ bytes.set(byteChunk, offset)
193
+ offset += byteChunk.length
194
+ }
195
+ if (offset !== bytes.length) {
196
+ fail("get-object-version", "AWS S3 exact object stream length disagrees with Content-Length.")
197
+ }
198
+ return bytes
199
+ } finally {
200
+ signal.removeEventListener("abort", stop)
201
+ }
202
+ }, deadlineMilliseconds)
203
+ }
204
+
205
+ const makePolicyCoordinates = (options: AwsS3JournalBoundaryOptions): AwsJournalPolicyCoordinates => ({
206
+ accountId: options.authority.accountId,
207
+ bucketArn: options.authority.bucketArn,
208
+ roleArn: options.authority.roleArn,
209
+ rolePolicyName: options.rolePolicyName,
210
+ oidcTrust: options.authority.oidcTrust
211
+ })
212
+
213
+ const observeAuthority = (
214
+ options: AwsS3JournalBoundaryOptions,
215
+ session: AwsJournalSession,
216
+ clients: AwsJournalClients,
217
+ deadlineMilliseconds: number
218
+ ): Effect.Effect<S3JournalAuthority, S3JournalBoundaryError> => Effect.tryPromise({
219
+ try: async () => {
220
+ const authority = options.authority
221
+ if (!/^[A-Za-z0-9+=,.@_-]{1,128}$/u.test(options.rolePolicyName)) {
222
+ fail("observe-authority", "Journal inline role policy name is not canonical.")
223
+ }
224
+ const name = roleName(authority.roleArn)
225
+ const [
226
+ roleResponse,
227
+ inlineResponse,
228
+ attachedResponse,
229
+ versioningResponse,
230
+ lockResponse,
231
+ ownershipResponse,
232
+ publicAccessResponse,
233
+ bucketPolicyResponse,
234
+ bucketPolicyStatusResponse,
235
+ callerIdentityResponse
236
+ ] = await Promise.all([
237
+ withOperationJournalNetworkDeadline((signal) => clients.iam.send(
238
+ new GetRoleCommand({ RoleName: name }), { abortSignal: signal }
239
+ ), deadlineMilliseconds),
240
+ withOperationJournalNetworkDeadline((signal) => clients.iam.send(
241
+ new ListRolePoliciesCommand({ RoleName: name, MaxItems: 2 }), { abortSignal: signal }
242
+ ), deadlineMilliseconds),
243
+ withOperationJournalNetworkDeadline((signal) => clients.iam.send(
244
+ new ListAttachedRolePoliciesCommand({ RoleName: name, MaxItems: 1 }), { abortSignal: signal }
245
+ ), deadlineMilliseconds),
246
+ withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetBucketVersioningCommand({
247
+ Bucket: authority.bucketName,
248
+ ExpectedBucketOwner: authority.expectedBucketOwner
249
+ }), { abortSignal: signal }), deadlineMilliseconds),
250
+ withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetObjectLockConfigurationCommand({
251
+ Bucket: authority.bucketName,
252
+ ExpectedBucketOwner: authority.expectedBucketOwner
253
+ }), { abortSignal: signal }), deadlineMilliseconds),
254
+ withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetBucketOwnershipControlsCommand({
255
+ Bucket: authority.bucketName,
256
+ ExpectedBucketOwner: authority.expectedBucketOwner
257
+ }), { abortSignal: signal }), deadlineMilliseconds),
258
+ withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetPublicAccessBlockCommand({
259
+ Bucket: authority.bucketName,
260
+ ExpectedBucketOwner: authority.expectedBucketOwner
261
+ }), { abortSignal: signal }), deadlineMilliseconds),
262
+ withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetBucketPolicyCommand({
263
+ Bucket: authority.bucketName,
264
+ ExpectedBucketOwner: authority.expectedBucketOwner
265
+ }), { abortSignal: signal }), deadlineMilliseconds),
266
+ withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetBucketPolicyStatusCommand({
267
+ Bucket: authority.bucketName,
268
+ ExpectedBucketOwner: authority.expectedBucketOwner
269
+ }), { abortSignal: signal }), deadlineMilliseconds),
270
+ withOperationJournalNetworkDeadline((signal) => clients.sts.send(
271
+ new GetCallerIdentityCommand({}), { abortSignal: signal }
272
+ ), deadlineMilliseconds)
273
+ ])
274
+ const role = roleResponse.Role
275
+ if (role?.Arn !== authority.roleArn || role.RoleName !== name ||
276
+ role.AssumeRolePolicyDocument === undefined || role.PermissionsBoundary !== undefined ||
277
+ role.MaxSessionDuration === undefined || role.MaxSessionDuration < 900) {
278
+ fail("observe-authority", "AWS journal role identity or session governance drifted.")
279
+ }
280
+ if (inlineResponse.IsTruncated === true || inlineResponse.PolicyNames?.length !== 1 ||
281
+ inlineResponse.PolicyNames[0] !== options.rolePolicyName ||
282
+ attachedResponse.IsTruncated === true || (attachedResponse.AttachedPolicies?.length ?? 0) !== 0) {
283
+ fail("observe-authority", "AWS journal role does not contain exactly one inline policy and zero managed policies.")
284
+ }
285
+ const rolePolicyResponse = await withOperationJournalNetworkDeadline((signal) => clients.iam.send(
286
+ new GetRolePolicyCommand({
287
+ RoleName: name,
288
+ PolicyName: options.rolePolicyName
289
+ }),
290
+ { abortSignal: signal }
291
+ ), deadlineMilliseconds)
292
+ if (rolePolicyResponse.RoleName !== name || rolePolicyResponse.PolicyName !== options.rolePolicyName ||
293
+ rolePolicyResponse.PolicyDocument === undefined) {
294
+ fail("observe-authority", "AWS journal inline role policy response is incomplete.")
295
+ }
296
+ if (versioningResponse.Status !== "Enabled") {
297
+ fail("observe-authority", "AWS journal bucket versioning is not enabled.")
298
+ }
299
+ const retention = lockResponse.ObjectLockConfiguration?.Rule?.DefaultRetention
300
+ if (lockResponse.ObjectLockConfiguration?.ObjectLockEnabled !== "Enabled" ||
301
+ retention?.Mode !== "COMPLIANCE" || retention.Years !== 10 || retention.Days !== undefined) {
302
+ fail("observe-authority", "AWS journal bucket Object Lock is not ten-year COMPLIANCE retention.")
303
+ }
304
+ const ownershipRules = ownershipResponse.OwnershipControls?.Rules
305
+ if (ownershipRules?.length !== 1 || ownershipRules[0]?.ObjectOwnership !== "BucketOwnerEnforced") {
306
+ fail("observe-authority", "AWS journal bucket ownership is not BucketOwnerEnforced.")
307
+ }
308
+ const publicBlock = publicAccessResponse.PublicAccessBlockConfiguration
309
+ if (publicBlock?.BlockPublicAcls !== true || publicBlock.IgnorePublicAcls !== true ||
310
+ publicBlock.BlockPublicPolicy !== true || publicBlock.RestrictPublicBuckets !== true ||
311
+ bucketPolicyStatusResponse.PolicyStatus?.IsPublic !== false) {
312
+ fail("observe-authority", "AWS journal bucket public access is not completely blocked.")
313
+ }
314
+ if (callerIdentityResponse.Account !== authority.accountId ||
315
+ callerIdentityResponse.Arn !== session.callerArn ||
316
+ callerIdentityResponse.UserId === undefined || callerIdentityResponse.UserId.length === 0) {
317
+ fail("observe-authority", "AWS STS caller identity drifted from the exact journal role session.")
318
+ }
319
+ const policyDigests = validateAwsJournalPolicies({
320
+ coordinates: makePolicyCoordinates(options),
321
+ trustPolicyDocument: role!.AssumeRolePolicyDocument as string,
322
+ rolePolicyDocument: rolePolicyResponse.PolicyDocument as string,
323
+ bucketPolicyDocument: requireString(bucketPolicyResponse.Policy, "observe-authority", "AWS journal bucket policy")
324
+ })
325
+ assertAwsJournalPolicyDigests(authority, policyDigests)
326
+ const oidcTrust = {
327
+ audience: session.oidc.audience,
328
+ subject: session.oidc.subject,
329
+ repository: session.oidc.repository,
330
+ repositoryId: session.oidc.repositoryId,
331
+ repositoryOwnerId: session.oidc.repositoryOwnerId,
332
+ workflow: session.oidc.workflow,
333
+ ref: session.oidc.ref,
334
+ environment: session.oidc.environment,
335
+ jobWorkflowRef: session.oidc.jobWorkflowRef
336
+ }
337
+ return {
338
+ accountId: authority.accountId,
339
+ bucketName: authority.bucketName,
340
+ bucketArn: authority.bucketArn,
341
+ region: authority.region,
342
+ roleArn: authority.roleArn,
343
+ prefix: "operation-journal/v1",
344
+ expectedBucketOwner: authority.expectedBucketOwner,
345
+ versioning: "Enabled",
346
+ objectLock: "Enabled",
347
+ retentionMode: "COMPLIANCE",
348
+ retentionYears: 10,
349
+ bucketOwnerEnforced: true,
350
+ publicAccessBlocked: true,
351
+ deleteDenied: true,
352
+ multipartDenied: true,
353
+ conditionalWritesEnforced: true,
354
+ ...policyDigests,
355
+ oidc: session.oidc,
356
+ oidcTrust
357
+ }
358
+ },
359
+ catch: (cause) => boundaryError(cause, "observe-authority", "AWS journal authority observation failed.")
360
+ })
361
+
362
+ const listNamespace = (
363
+ options: AwsS3JournalBoundaryOptions,
364
+ client: S3Client,
365
+ input: Parameters<S3JournalBoundaryShape["listNamespace"]>[0],
366
+ deadlineMilliseconds: number
367
+ ): Effect.Effect<S3JournalNamespaceListing, S3JournalBoundaryError> => Effect.tryPromise({
368
+ try: async () => {
369
+ requireSealedInput(input, options.authority, "list-namespace")
370
+ if (!journalNamespacePattern.test(input.prefix) || input.maximum !== 512) {
371
+ fail("list-namespace", "AWS journal namespace read escaped its exact bound.")
372
+ }
373
+ const response = await withOperationJournalNetworkDeadline((signal) => client.send(
374
+ new ListObjectVersionsCommand({
375
+ Bucket: input.bucketName,
376
+ Prefix: input.prefix,
377
+ MaxKeys: input.maximum,
378
+ ExpectedBucketOwner: input.expectedBucketOwner
379
+ }),
380
+ { abortSignal: signal }
381
+ ), deadlineMilliseconds)
382
+ if (response.Name !== input.bucketName || response.Prefix !== input.prefix ||
383
+ response.MaxKeys !== input.maximum || typeof response.IsTruncated !== "boolean") {
384
+ fail("list-namespace", "AWS S3 version listing identity or bound drifted from the exact request.")
385
+ }
386
+ if ((response.Versions?.length ?? 0) + (response.DeleteMarkers?.length ?? 0) > input.maximum) {
387
+ fail("list-namespace", "AWS S3 version listing exceeded its exact response bound.")
388
+ }
389
+ const versions = (response.Versions ?? []).map((version, index) => {
390
+ const key = requireString(version.Key, "list-namespace", `AWS S3 version ${index} key`)
391
+ const versionId = requireString(version.VersionId, "list-namespace", `AWS S3 version ${index} VersionId`)
392
+ const etag = requireString(version.ETag, "list-namespace", `AWS S3 version ${index} ETag`)
393
+ if ((!journalEventKeyPattern.test(key) && !journalHeadKeyPattern.test(key)) ||
394
+ !isCanonicalS3VersionId(versionId) || !/^"[!#-~]{1,1024}"$/u.test(etag)) {
395
+ fail("list-namespace", `AWS S3 version ${index} identity is not canonical.`)
396
+ }
397
+ return { key, versionId, etag, isLatest: version.IsLatest === true }
398
+ })
399
+ const deleteMarkers = (response.DeleteMarkers ?? []).map((marker, index) => {
400
+ const key = requireString(marker.Key, "list-namespace", `AWS S3 delete marker ${index} key`)
401
+ const versionId = requireString(marker.VersionId, "list-namespace", `AWS S3 delete marker ${index} VersionId`)
402
+ if ((!journalEventKeyPattern.test(key) && !journalHeadKeyPattern.test(key)) ||
403
+ !isCanonicalS3VersionId(versionId)) {
404
+ fail("list-namespace", `AWS S3 delete marker ${index} identity is not canonical.`)
405
+ }
406
+ return { key, versionId }
407
+ })
408
+ if (versions.some((version) => !version.key.startsWith(input.prefix)) ||
409
+ deleteMarkers.some((marker) => !marker.key.startsWith(input.prefix))) {
410
+ fail("list-namespace", "AWS S3 returned an object outside the exact journal prefix.")
411
+ }
412
+ return { versions, deleteMarkers, truncated: response.IsTruncated === true }
413
+ },
414
+ catch: (cause) => boundaryError(cause, "list-namespace", `AWS S3 journal namespace read failed (${errorName(cause)}).`)
415
+ })
416
+
417
+ const getObjectVersion = (
418
+ options: AwsS3JournalBoundaryOptions,
419
+ client: S3Client,
420
+ input: Parameters<S3JournalBoundaryShape["getObjectVersion"]>[0],
421
+ deadlineMilliseconds: number
422
+ ): Effect.Effect<S3JournalObjectVersion, S3JournalBoundaryError> => Effect.tryPromise({
423
+ try: async () => {
424
+ requireSealedInput(input, options.authority, "get-object-version")
425
+ if ((!journalEventKeyPattern.test(input.key) && !journalHeadKeyPattern.test(input.key)) ||
426
+ !isCanonicalS3VersionId(input.versionId)) {
427
+ fail("get-object-version", "AWS journal exact-version read escaped its sealed namespace.")
428
+ }
429
+ const response = await withOperationJournalNetworkDeadline((signal) => client.send(
430
+ new GetObjectCommand({
431
+ Bucket: input.bucketName,
432
+ Key: input.key,
433
+ VersionId: input.versionId,
434
+ ExpectedBucketOwner: input.expectedBucketOwner,
435
+ ChecksumMode: "ENABLED"
436
+ }),
437
+ { abortSignal: signal }
438
+ ), deadlineMilliseconds)
439
+ if (response.Body === undefined || response.ContentLength === undefined || response.LastModified === undefined ||
440
+ response.ObjectLockMode !== "COMPLIANCE" || response.ObjectLockRetainUntilDate === undefined ||
441
+ (response.ChecksumType !== undefined && response.ChecksumType !== "FULL_OBJECT")) {
442
+ fail("get-object-version", "AWS S3 exact object response lacks bytes, time, checksum, or COMPLIANCE retention.")
443
+ }
444
+ const retainUntil = response.ObjectLockRetainUntilDate as Date
445
+ const body = response.Body!
446
+ const lastModified = response.LastModified as Date
447
+ const retention = await withOperationJournalNetworkDeadline((signal) => client.send(
448
+ new GetObjectRetentionCommand({
449
+ Bucket: input.bucketName,
450
+ Key: input.key,
451
+ VersionId: input.versionId,
452
+ ExpectedBucketOwner: input.expectedBucketOwner
453
+ }),
454
+ { abortSignal: signal }
455
+ ), deadlineMilliseconds)
456
+ if (retention.Retention?.Mode !== "COMPLIANCE" || retention.Retention.RetainUntilDate === undefined ||
457
+ retention.Retention.RetainUntilDate.getTime() !== retainUntil.getTime()) {
458
+ fail("get-object-version", "AWS S3 exact object retention disagrees with its independent retention read.")
459
+ }
460
+ const bytes = await readBoundedObjectBody(body, response.ContentLength as number, deadlineMilliseconds)
461
+ const attributes = await withOperationJournalNetworkDeadline((signal) => client.send(
462
+ new GetObjectAttributesCommand({
463
+ Bucket: input.bucketName,
464
+ Key: input.key,
465
+ VersionId: input.versionId,
466
+ ExpectedBucketOwner: input.expectedBucketOwner,
467
+ ObjectAttributes: ["Checksum", "ETag", "ObjectSize"]
468
+ }),
469
+ { abortSignal: signal }
470
+ ), deadlineMilliseconds)
471
+ const responseVersionId = requireString(response.VersionId, "get-object-version", "AWS S3 exact object VersionId")
472
+ const checksumSha256 = checksumFromBase64(response.ChecksumSHA256, "get-object-version")
473
+ if (responseVersionId !== input.versionId || attributes.DeleteMarker === true || attributes.VersionId !== input.versionId ||
474
+ attributes.ETag !== response.ETag || attributes.ObjectSize !== bytes.length ||
475
+ attributes.Checksum?.ChecksumType !== "FULL_OBJECT" ||
476
+ checksumFromBase64(attributes.Checksum.ChecksumSHA256, "get-object-version") !== checksumSha256) {
477
+ fail("get-object-version", "AWS S3 exact object attributes disagree with its retained bytes and version.")
478
+ }
479
+ return {
480
+ key: input.key,
481
+ versionId: responseVersionId,
482
+ etag: requireString(response.ETag, "get-object-version", "AWS S3 exact object ETag"),
483
+ checksumSha256,
484
+ bytes,
485
+ lastModified: lastModified.toISOString(),
486
+ retentionMode: "COMPLIANCE",
487
+ retainUntil: retainUntil.toISOString()
488
+ }
489
+ },
490
+ catch: (cause) => boundaryError(cause, "get-object-version", `AWS S3 exact journal object read failed (${errorName(cause)}).`)
491
+ })
492
+
493
+ const putObject = (
494
+ options: AwsS3JournalBoundaryOptions,
495
+ client: S3Client,
496
+ input: S3JournalPutRequest,
497
+ operation: "put-event" | "put-head",
498
+ deadlineMilliseconds: number
499
+ ): Effect.Effect<S3JournalPutResult, S3JournalBoundaryError> => Effect.tryPromise({
500
+ try: async () => {
501
+ requireSealedInput(input, options.authority, operation)
502
+ if (operation === "put-event" ? !journalEventKeyPattern.test(input.key) : !journalHeadKeyPattern.test(input.key)) {
503
+ fail(operation, "AWS S3 conditional write key is outside its exact journal object class.")
504
+ }
505
+ if (!(input.bytes instanceof Uint8Array) || input.bytes.length < 1 ||
506
+ input.bytes.length > operationJournalByteLimits.object) {
507
+ fail(operation, "AWS S3 conditional write bytes exceed the admitted object bound.")
508
+ }
509
+ if (objectChecksum(input.bytes) !== input.checksumSha256) {
510
+ fail(operation, "AWS S3 conditional write checksum does not match its exact bytes.")
511
+ }
512
+ if (input.condition._tag === "IfMatch" && !/^"[!#-~]{1,1024}"$/u.test(input.condition.etag)) {
513
+ fail(operation, "AWS S3 If-Match ETag is not canonical.")
514
+ }
515
+ const checksum = checksumToBase64(input.checksumSha256, operation)
516
+ let response
517
+ try {
518
+ response = await withOperationJournalNetworkDeadline((signal) => client.send(
519
+ new PutObjectCommand({
520
+ Bucket: input.bucketName,
521
+ Key: input.key,
522
+ Body: new Uint8Array(input.bytes),
523
+ ContentLength: input.bytes.length,
524
+ ContentType: "application/octet-stream",
525
+ ChecksumAlgorithm: "SHA256",
526
+ ChecksumSHA256: checksum,
527
+ ExpectedBucketOwner: input.expectedBucketOwner,
528
+ ...(input.condition._tag === "IfNoneMatch"
529
+ ? { IfNoneMatch: "*" }
530
+ : { IfMatch: input.condition.etag })
531
+ }),
532
+ { abortSignal: signal }
533
+ ), deadlineMilliseconds)
534
+ } catch (cause) {
535
+ const status = statusCode(cause)
536
+ if (status === 409 || status === 412) return { _tag: "PreconditionFailed" }
537
+ throw cause
538
+ }
539
+ const versionId = requireString(response.VersionId, operation, "AWS S3 committed object VersionId")
540
+ const etag = requireString(response.ETag, operation, "AWS S3 committed object ETag")
541
+ const responseChecksum = checksumFromBase64(response.ChecksumSHA256, operation)
542
+ if (responseChecksum !== input.checksumSha256 ||
543
+ (response.ChecksumType !== undefined && response.ChecksumType !== "FULL_OBJECT")) {
544
+ fail(operation, "AWS S3 committed object checksum disagrees with the exact request bytes.")
545
+ }
546
+ return { _tag: "Committed", versionId, etag, checksumSha256: responseChecksum }
547
+ },
548
+ catch: (cause) => putFailure(cause, operation)
549
+ })
550
+
551
+ export const makeAwsS3JournalBoundaryFromClients = (
552
+ options: AwsS3JournalBoundaryOptions,
553
+ session: AwsJournalSession,
554
+ clients: AwsJournalClients,
555
+ deadlineMilliseconds = operationJournalNetworkDeadlineMilliseconds
556
+ ): S3JournalBoundaryShape => ({
557
+ observeAuthority: () => observeAuthority(options, session, clients, deadlineMilliseconds),
558
+ listNamespace: (input) => listNamespace(options, clients.s3, input, deadlineMilliseconds),
559
+ getObjectVersion: (input) => getObjectVersion(options, clients.s3, input, deadlineMilliseconds),
560
+ putEvent: (input) => putObject(options, clients.s3, input, "put-event", deadlineMilliseconds),
561
+ putHead: (input) => putObject(options, clients.s3, input, "put-head", deadlineMilliseconds)
562
+ })
@@ -0,0 +1,60 @@
1
+ import { IAMClient } from "@aws-sdk/client-iam"
2
+ import { S3Client } from "@aws-sdk/client-s3"
3
+ import { STSClient } from "@aws-sdk/client-sts"
4
+ import * as Effect from "effect/Effect"
5
+ import {
6
+ S3JournalBoundaryError,
7
+ type S3JournalBoundaryShape
8
+ } from "./model.js"
9
+ import { validateS3JournalAuthority } from "./authority.js"
10
+ import { acquireAwsJournalSession } from "./aws/oidc.js"
11
+ import {
12
+ makeAwsS3JournalBoundaryFromClients,
13
+ type AwsS3JournalBoundaryOptions
14
+ } from "./aws/s3-boundary.js"
15
+
16
+ /**
17
+ * Acquire one fresh GitHub OIDC/STS session and seal an AWS S3 boundary around
18
+ * the exact activation contract. The constructor never consults the AWS
19
+ * credential chain and exposes no endpoint, profile, or credential override.
20
+ */
21
+ export const makeAwsS3JournalBoundary = (
22
+ options: AwsS3JournalBoundaryOptions
23
+ ): Effect.Effect<S3JournalBoundaryShape, S3JournalBoundaryError> => Effect.gen(function*() {
24
+ yield* Effect.try({
25
+ try: () => {
26
+ validateS3JournalAuthority(options.authority)
27
+ if (!/^[A-Za-z0-9+=,.@_-]{1,128}$/u.test(options.rolePolicyName)) {
28
+ throw new Error("invalid role policy name")
29
+ }
30
+ },
31
+ catch: () => S3JournalBoundaryError.make({
32
+ operation: "observe-authority",
33
+ commitment: "not-applicable",
34
+ reason: "AWS journal activation contract is malformed."
35
+ })
36
+ })
37
+ const session = yield* acquireAwsJournalSession(options.authority)
38
+ const credentials = session.credentials
39
+ const iam = new IAMClient({
40
+ region: options.authority.region,
41
+ credentials,
42
+ maxAttempts: 1
43
+ })
44
+ const s3 = new S3Client({
45
+ region: options.authority.region,
46
+ credentials,
47
+ maxAttempts: 1,
48
+ followRegionRedirects: false,
49
+ forcePathStyle: false,
50
+ useArnRegion: false
51
+ })
52
+ const sts = new STSClient({
53
+ region: options.authority.region,
54
+ credentials,
55
+ maxAttempts: 1
56
+ })
57
+ return makeAwsS3JournalBoundaryFromClients(options, session, { iam, s3, sts })
58
+ })
59
+
60
+ export type { AwsS3JournalBoundaryOptions } from "./aws/s3-boundary.js"