@sentropic/artifacts 0.1.4 → 0.2.1

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 (69) hide show
  1. package/README.md +47 -0
  2. package/dist/adapters/postgres-commit.d.ts +15 -0
  3. package/dist/adapters/postgres-commit.js +80 -0
  4. package/dist/adapters/postgres.d.ts +5 -2
  5. package/dist/adapters/postgres.js +39 -0
  6. package/dist/http.d.ts +43 -30
  7. package/dist/http.js +158 -507
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +2 -0
  10. package/dist/mcp-core.d.ts +32 -39
  11. package/dist/mcp-core.js +83 -244
  12. package/dist/mcp.js +5 -5
  13. package/dist/profile/contracts.d.ts +67 -0
  14. package/dist/profile/contracts.js +31 -0
  15. package/dist/profile/index.d.ts +6 -0
  16. package/dist/profile/index.js +117 -0
  17. package/dist/profile/json.d.ts +10 -0
  18. package/dist/profile/json.js +76 -0
  19. package/dist/profile/radar.d.ts +3 -0
  20. package/dist/profile/radar.js +21 -0
  21. package/dist/profile/schemas.d.ts +6 -0
  22. package/dist/profile/schemas.js +76 -0
  23. package/dist/profile/validation.d.ts +5 -0
  24. package/dist/profile/validation.js +104 -0
  25. package/dist/profile/validators.d.ts +3 -0
  26. package/dist/profile/validators.js +51 -0
  27. package/dist/server/authorized-replay.d.ts +33 -0
  28. package/dist/server/authorized-replay.js +31 -0
  29. package/dist/server/command-identity.d.ts +52 -0
  30. package/dist/server/command-identity.js +71 -0
  31. package/dist/server/commit/brands.d.ts +7 -0
  32. package/dist/server/commit/brands.js +3 -0
  33. package/dist/server/commit/coordinator.d.ts +2 -0
  34. package/dist/server/commit/coordinator.js +729 -0
  35. package/dist/server/commit/types.d.ts +382 -0
  36. package/dist/server/commit/types.js +94 -0
  37. package/dist/server/commit-contract.d.ts +4 -0
  38. package/dist/server/commit-contract.js +2 -0
  39. package/dist/server/failures.d.ts +84 -0
  40. package/dist/server/failures.js +133 -0
  41. package/dist/server/gc.d.ts +42 -1
  42. package/dist/server/gc.js +168 -5
  43. package/dist/server/idempotency.d.ts +4 -0
  44. package/dist/server/idempotency.js +14 -0
  45. package/dist/server/lifecycle.d.ts +2 -1
  46. package/dist/server/lifecycle.js +15 -1
  47. package/dist/server/object-intents.d.ts +14 -1
  48. package/dist/server/object-intents.js +31 -0
  49. package/dist/server/outcome-contract.d.ts +3 -0
  50. package/dist/server/outcome-contract.js +3 -0
  51. package/dist/server/outcome-projection.d.ts +21 -0
  52. package/dist/server/outcome-projection.js +29 -0
  53. package/dist/server/preparation-cleanup.d.ts +31 -0
  54. package/dist/server/preparation-cleanup.js +106 -0
  55. package/dist/server/publication.d.ts +78 -0
  56. package/dist/server/publication.js +263 -31
  57. package/dist/server/service.d.ts +60 -0
  58. package/dist/server/service.js +181 -0
  59. package/dist/testing.d.ts +50 -0
  60. package/dist/testing.js +86 -0
  61. package/docs/migration-l6.md +134 -0
  62. package/examples/radar-commit-setup.ts +302 -0
  63. package/l0-freeze/command-identity-contract.ts +303 -0
  64. package/l0-freeze/commit-contract.ts +421 -0
  65. package/l0-freeze/failure-contract.ts +174 -0
  66. package/l0-freeze/profile-contract.ts +219 -0
  67. package/migrations/0003_coordinator.sql +28 -0
  68. package/package.json +27 -1
  69. package/src/contracts.ts +125 -0
package/dist/http.js CHANGED
@@ -1,533 +1,184 @@
1
- /**
2
- * L-5c — transport-free HTTP app (spec-v8 §9 5c).
3
- *
4
- * Part 1: request/response types, ordered error mapping, dispatch and the
5
- * ensure route. The app delegates to the same services as direct calls
6
- * (parity); only the DTO validation and status mapping live here. Created
7
- * views are owned by the caller — no client-supplied owner. Part 2 adds
8
- * publish/read; part 3 adds lifecycle/fork/access/source routes.
9
- *
10
- * Status mapping (first match wins): access denied → 403; state conflicts
11
- * (already/conflict/lost races, premature purge) → 409; unknown
12
- * artifact/release/intent/upload/blob or missing source → 404; terminal or
13
- * removed states (purged/deleted) → 410; failed byte verification → 422;
14
- * invalid input → 400; anything else → 500 (never expected).
15
- *
16
- * m5 hardening: error bodies are stable codes only (never raw internal
17
- * messages, which can carry driver text or stored identifiers). An actor
18
- * who cannot read an artifact gets the identical 404 answer as for an
19
- * unknown artifact on every route — no 404/403 existence oracle. Precise
20
- * 403 answers remain for actors who can already read the artifact.
21
- */
22
- import { buildEnsureRequest } from './client.js';
23
- import { DEFAULT_PUBLICATION_LIMITS } from './server/publication.js';
24
- import { validatePublicationFiles } from './server/publication-limits.js';
25
- import { createContentRuntime } from './server/runtime.js';
26
- import { evaluateAccess, grantAccess, revokeAccess } from './server/access.js';
27
- import { parseSourceRef } from './server/sources.js';
28
- import { assertArtifactAccess } from './server/authorization.js';
29
- function errorStatus(message) {
30
- if (/access denied/.test(message))
31
- return 403;
32
- if (/can only purge|already |conflict|lost/.test(message))
33
- return 409;
34
- if (/unknown artifact|unknown release|unknown intent|unknown upload|unknown blob|not found|no source|no release/.test(message)) {
35
- return 404;
36
- }
37
- if (/terminal|purged|deleted/.test(message))
38
- return 410;
39
- if (/digest|size mismatch|read-back/.test(message))
40
- return 422;
41
- if (/invalid|missing|empty|duplicate|unknown action|unknown kind|budget|offset|committed|scope mismatch|malformed|forged|byte/.test(message)) {
42
- return 400;
43
- }
44
- return 500;
45
- }
46
- const ERROR_CODES = {
47
- 400: 'invalid_request',
48
- 401: 'anonymous',
49
- 403: 'access_denied',
50
- 404: 'not_found',
51
- 409: 'conflict',
52
- 410: 'gone',
53
- 422: 'verification_failed',
54
- 500: 'internal_error',
55
- };
56
- /** Stable error body for one status; internal messages never leave the app. */
1
+ import { parseManifest } from './codecs.js';
2
+ import { assertCompiledArtifactsProfile, ArtifactProfileInputError } from './profile/index.js';
3
+ import { createArtifactCommand } from './server/command-identity.js';
4
+ import { createGuardedEnsureService } from './server/service.js';
5
+ import { assertArtifactReplayMigration } from './server/idempotency.js';
6
+ import { createGuardedPublicationService } from './server/publication.js';
7
+ import { CommitQuarantineError } from './server/commit/types.js';
8
+ import { ArtifactOperationError, classifyArtifactFailure } from './server/failures.js';
9
+ import { projectArtifactHttpOutcome } from './server/outcome-projection.js';
10
+ export { projectArtifactHttpOutcome } from './server/outcome-projection.js';
11
+ /** Stable content-route error codes; private driver messages never leave the host. */
57
12
  export function errorBody(status) {
58
- return { error: ERROR_CODES[status] ?? 'internal_error' };
59
- }
60
- function fail(error) {
61
- const message = error instanceof Error ? error.message : String(error);
62
- const status = errorStatus(message);
63
- return { status, body: errorBody(status) };
13
+ return { error: { 400: 'invalid_request', 401: 'anonymous', 403: 'access_denied',
14
+ 404: 'not_found', 409: 'conflict', 410: 'gone', 422: 'verification_failed',
15
+ 500: 'internal_error', 503: 'capability_unavailable' }[status] ?? 'internal_error' };
64
16
  }
65
- function needObject(body) {
66
- if (typeof body !== 'object' || body === null || Array.isArray(body)) {
67
- throw new Error('invalid request: body must be an object');
68
- }
69
- return body;
70
- }
71
- function needString(value, name) {
72
- if (typeof value !== 'string' || value.length === 0)
73
- throw new Error(`invalid request: missing ${name}`);
17
+ /** Retained for consumers describing canonical textual publication inputs. */
18
+ export const TEXT_MIME_PATTERN = '^(?:text/[a-z0-9!#$&^_.+-]+|application/(?:javascript|ecmascript|json|xml|[a-z0-9!#$&^_.+-]+\\+(?:json|xml))|image/svg\\+xml)(?![\\s\\S])';
19
+ function object(value) {
20
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
21
+ throw new ArtifactProfileInputError();
74
22
  return value;
75
23
  }
76
- function needBytes(value, maxBytes) {
77
- if (!Array.isArray(value))
78
- throw new Error('invalid file bytes: must be an array');
79
- if (value.length > maxBytes)
80
- throw new Error('invalid publication byte budget');
81
- const bytes = new Uint8Array(value.length);
82
- for (let i = 0; i < value.length; i += 1) {
83
- const byte = value[i];
84
- if (typeof byte !== 'number' || !Number.isInteger(byte) || byte < 0 || byte > 255) {
85
- throw new Error(`invalid file bytes: byte ${i} out of range`);
86
- }
87
- bytes[i] = byte;
88
- }
89
- return bytes;
90
- }
91
- /** Canonical text MIME types; parameters are omitted (encoding is always UTF-8). */
92
- export const TEXT_MIME_PATTERN = '^(?:text/[a-z0-9!#$&^_.+-]+|application/(?:javascript|ecmascript|json|xml|[a-z0-9!#$&^_.+-]+\\+(?:json|xml))|image/svg\\+xml)(?![\\s\\S])';
93
- function needFileBytes(entry, mimeType, maxBytes) {
94
- const hasBytes = 'bytes' in entry;
95
- const hasText = 'text' in entry;
96
- if (hasBytes === hasText)
97
- throw new Error('invalid file: supply exactly one of bytes or text');
98
- if (hasBytes)
99
- return needBytes(entry['bytes'], maxBytes);
100
- if (typeof entry['text'] !== 'string' || !new RegExp(TEXT_MIME_PATTERN).test(mimeType)) {
101
- throw new Error('invalid file text: expected a string and a canonical text MIME type');
24
+ /** Other mutation surfaces need their own qualification before host activation. */
25
+ export function assertArtifactsTransportProfile(value) {
26
+ assertCompiledArtifactsProfile(value);
27
+ const supported = ['artifacts_ensure', 'artifacts_publish', 'artifacts_list', 'artifacts_get'];
28
+ if (value.selectedCapabilities.some((name) => !supported.includes(name))) {
29
+ throw new Error('unsupported artifacts transport capability');
102
30
  }
103
- if (Buffer.byteLength(entry['text'], 'utf8') > maxBytes)
104
- throw new Error('invalid publication byte budget');
105
- return new TextEncoder().encode(entry['text']);
106
31
  }
107
- /** Create the HTTP app over injected services (no server, no fetch). */
108
32
  export function createHttpArtifactApp(deps) {
109
- const isApproved = deps.isApprovedAccount ?? (() => false);
110
- const runtime = createContentRuntime({
111
- acls: deps.acls,
112
- lifecycle: deps.lifecycle,
113
- publication: deps.publication,
114
- blobs: deps.blobs,
115
- });
116
- /**
117
- * Load the ACL only when the actor may learn the artifact exists: the
118
- * owner always, everyone else only with a live read right. Unknown and
119
- * unreadable artifacts throw the identical error (no existence oracle).
120
- */
121
- // Upload sessions are bound to their creating owner + artifact in-app
122
- // (the upload service itself is actor-agnostic staging).
123
- const uploadOwners = new Map();
124
- function requireUploads() {
125
- if (deps.uploads === undefined || deps.staging === undefined) {
126
- throw new Error('invalid request: uploads are not configured on this host');
127
- }
128
- return { uploads: deps.uploads, staging: deps.staging };
33
+ assertArtifactsTransportProfile(deps.profile);
34
+ if (typeof deps.coordinator?.finalizePrepared !== 'function' || typeof deps.coordinator.replayPrepared !== 'function') {
35
+ throw new Error('artifacts requires an atomic commit coordinator');
129
36
  }
130
- function requireTemplates() {
131
- if (deps.templates === undefined) {
132
- throw new Error('invalid request: templates are not configured on this host');
133
- }
134
- return deps.templates;
135
- }
136
- function requireUploadOwner(uploadId, actorId) {
137
- const binding = uploadOwners.get(uploadId);
138
- if (binding === undefined || binding.ownerId !== actorId) {
139
- throw new Error(`unknown upload: ${uploadId}`);
140
- }
141
- return binding;
37
+ if (deps.derivation?.algorithm !== deps.profile.rules.keyDerivationAlgorithm) {
38
+ throw new Error('artifacts profile/derivation mismatch');
142
39
  }
143
- async function requireVisible(artifactId, actorId) {
40
+ if (typeof deps.legacyRecords?.get !== 'function')
41
+ throw new Error('artifacts requires a legacy record inventory');
42
+ const profile = deps.profile;
43
+ const ensure = createGuardedEnsureService({ profile, coordinator: deps.coordinator,
44
+ derivation: deps.derivation, host: deps.ensureHost });
45
+ async function visible(artifactId, invocation) {
144
46
  const acl = await deps.acls.getArtifactAcl(artifactId);
145
- if (acl === undefined)
146
- throw new Error(`unknown artifact: ${artifactId}`);
147
- if (acl.ownerId === actorId)
148
- return acl;
47
+ if (!acl || acl.storageScopeId !== invocation.storageScopeId)
48
+ throw new ArtifactOperationError('not_found');
149
49
  const state = await deps.lifecycle.stateOf(artifactId);
150
- if (!deps.lifecycle.canRead({ artifactId, state }, acl, actorId, 'read', isApproved)) {
151
- throw new Error(`unknown artifact: ${artifactId}`);
50
+ if (!deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, 'read', deps.isApprovedAccount)) {
51
+ throw new ArtifactOperationError('not_found');
152
52
  }
153
53
  return acl;
154
54
  }
155
- return {
156
- async handle(request) {
55
+ const app = {
56
+ selectedCapabilities: profile.selectedCapabilities,
57
+ profileDigest: profile.profileDigest,
58
+ async execute(request, supplied) {
59
+ let operation = 'unsupported';
60
+ let phase = 'selection';
61
+ let commitState = 'not_committed';
62
+ let invocation = supplied;
157
63
  try {
158
- if (request.actorId.length === 0)
159
- return { status: 401, body: errorBody(401) };
160
- const body = request.body === undefined ? {} : needObject(request.body);
161
- if (request.method === 'POST' && request.path === '/ensure') {
162
- const ensured = buildEnsureRequest(deps.parameterSchema, {
163
- bindingName: needString(body['bindingName'], 'bindingName'),
164
- storageScopeId: needString(request.storageScopeId, 'host storageScopeId'),
165
- idempotencyKey: needString(body['idempotencyKey'], 'idempotencyKey'),
166
- paramsHash: '',
167
- parameters: (body['parameters'] ?? {}),
168
- });
169
- const result = await deps.artifacts.ensurePrivateView({
170
- actorUserId: request.actorId,
171
- ownerId: request.actorId,
172
- storageScopeId: ensured.storageScopeId,
173
- bindingName: ensured.bindingName,
174
- idempotencyKey: ensured.idempotencyKey,
175
- parameters: ensured.parameters,
64
+ const capability = profile.capabilityForRoute(request.method, request.path) ??
65
+ (request.method === 'GET' && /^\/meta\/[^/]*$/.test(request.path) &&
66
+ profile.selectedCapabilities.includes('artifacts_get') ? 'artifacts_get' : undefined);
67
+ if (!capability)
68
+ throw new ArtifactProfileInputError(true);
69
+ operation = capability.slice('artifacts_'.length);
70
+ phase = 'entry';
71
+ if (!supplied || !supplied.invocationId || typeof supplied.authorizeCommit !== 'function') {
72
+ throw new ArtifactProfileInputError();
73
+ }
74
+ invocation = Object.freeze({ ...supplied });
75
+ if (!invocation.actorId)
76
+ throw new ArtifactOperationError('anonymous');
77
+ if (request.actorId !== invocation.actorId || !invocation.storageScopeId ||
78
+ (request.storageScopeId !== undefined && request.storageScopeId !== invocation.storageScopeId)) {
79
+ throw new ArtifactProfileInputError();
80
+ }
81
+ let args;
82
+ if (capability === 'artifacts_get') {
83
+ if (request.body !== undefined && Object.keys(object(request.body)).length)
84
+ throw new ArtifactProfileInputError();
85
+ args = { artifactId: request.path.slice('/meta/'.length) };
86
+ }
87
+ else {
88
+ args = { ...object(request.body ?? {}) };
89
+ if (Object.hasOwn(args, 'storageScopeId')) {
90
+ if (args.storageScopeId !== invocation.storageScopeId)
91
+ throw new ArtifactProfileInputError();
92
+ delete args.storageScopeId;
93
+ }
94
+ }
95
+ const normalized = profile.validate(capability, args, request.idempotencyKey);
96
+ const command = capability === 'artifacts_ensure' || capability === 'artifacts_publish'
97
+ ? createArtifactCommand(profile, capability, args, invocation, deps.derivation, request.idempotencyKey) : undefined;
98
+ if (command) {
99
+ phase = 'migration';
100
+ await assertArtifactReplayMigration(deps.legacyRecords, command, invocation, normalized.idempotencyKey);
101
+ }
102
+ phase = 'dispatch';
103
+ if (capability === 'artifacts_ensure') {
104
+ const outcome = await ensure.ensure(invocation, { ...normalized,
105
+ envelopeKey: request.idempotencyKey });
106
+ return outcome.ok ? { ...outcome, result: { status: 201, body: outcome.result } } : outcome;
107
+ }
108
+ if (capability === 'artifacts_publish') {
109
+ const artifactId = normalized.artifactId;
110
+ const acl = await visible(artifactId, invocation);
111
+ if (acl.ownerId !== invocation.actorId)
112
+ throw new ArtifactOperationError('access_denied');
113
+ const limits = profile.operations.find((entry) => entry.operation === 'publish').limits;
114
+ const publication = createGuardedPublicationService({ ...deps.publication, coordinator: deps.coordinator,
115
+ limits: { maxFiles: limits.publishFilesMax, maxFileBytes: limits.maxDecodedFileBytes,
116
+ maxTotalBytes: limits.maxDecodedTotalBytes } });
117
+ const outcome = await publication.publishFinalized({
118
+ ownerId: acl.ownerId, storageScopeId: invocation.storageScopeId, artifactId,
119
+ identity: command.identity, actorId: invocation.actorId, invocationId: invocation.invocationId,
120
+ attemptId: invocation.invocationId, policyRevision: await deps.ensureHost.readPolicyRevision(),
121
+ authorizeCommit: invocation.authorizeCommit,
122
+ source: { kind: 'inline-original' }, profile: normalized.profile,
123
+ files: normalized.files.map((file) => ({
124
+ path: file.path, mimeType: file.mimeType, bytes: Uint8Array.from(file.bytes),
125
+ })),
176
126
  });
177
- if ((await deps.acls.getArtifactAcl(result.artifactId)) === undefined) {
178
- await deps.acls.putArtifactAcl(result.artifactId, {
179
- ownerId: request.actorId,
180
- storageScopeId: ensured.storageScopeId,
181
- isPublic: false,
182
- withdrawn: false,
183
- grants: [],
184
- });
185
- }
186
- await deps.lifecycle.register(result.artifactId, request.actorId);
187
- return { status: 201, body: result };
188
- }
189
- if (request.method === 'POST' && request.path === '/publish') {
190
- const artifactId = needString(body['artifactId'], 'artifactId');
191
- const acl = await requireVisible(artifactId, request.actorId);
192
- assertArtifactAccess({ actorUserId: request.actorId, artifactOwnerId: acl.ownerId, action: 'mutate' });
193
- if (acl.storageScopeId === undefined)
194
- throw new Error('invalid request: missing storage scope');
195
- const rawFiles = body['files'];
196
- const limits = deps.publication.limits ?? DEFAULT_PUBLICATION_LIMITS;
197
- if (!Array.isArray(rawFiles) || rawFiles.length === 0 || rawFiles.length > limits.maxFiles) {
198
- throw new Error('invalid publication file count budget');
199
- }
200
- let remaining = limits.maxTotalBytes;
201
- const files = rawFiles.map((file) => {
202
- const entry = needObject(file);
203
- const mimeType = needString(entry['mimeType'], 'file.mimeType');
204
- const bytes = needFileBytes(entry, mimeType, Math.min(limits.maxFileBytes, remaining));
205
- remaining -= bytes.byteLength;
206
- return {
207
- path: needString(entry['path'], 'file.path'),
208
- mimeType,
209
- bytes,
210
- };
211
- });
212
- const source = body['source'];
213
- const attached = source === undefined ? undefined : parseSourceRef(source);
214
- const result = await deps.publication.publish({
215
- ownerId: acl.ownerId,
216
- storageScopeId: acl.storageScopeId,
217
- artifactId,
218
- profile: (body['profile'] ?? {}),
219
- files,
220
- source: attached,
221
- idempotencyKey: typeof body['idempotencyKey'] === 'string' ? body['idempotencyKey'] : undefined,
222
- paramsHash: typeof body['paramsHash'] === 'string' ? body['paramsHash'] : undefined,
223
- }, deps.sources);
224
- await deps.lifecycle.trackRelease(artifactId, result.releaseId);
225
- return { status: 201, body: result };
226
- }
227
- if (request.method === 'GET' && request.path.startsWith('/current/')) {
228
- const artifactId = request.path.slice('/current/'.length);
229
- if (artifactId.length === 0)
230
- return { status: 404, body: errorBody(404) };
231
- await requireVisible(artifactId, request.actorId);
127
+ if (!outcome.ok) {
128
+ const failure = { ...outcome.failure, kind: 'operational' };
129
+ Object.defineProperty(failure, 'cause', { value: outcome.failure.cause, enumerable: false });
130
+ return { ok: false, failure: Object.freeze(failure) };
131
+ }
132
+ commitState = outcome.commitState;
133
+ const receipt = object(outcome.result.receipt);
134
+ if (typeof receipt.releaseId !== 'string' || typeof receipt.manifest !== 'string') {
135
+ throw new Error('invalid publication receipt');
136
+ }
137
+ return { ...outcome, result: { status: 201, body: {
138
+ artifactId, releaseId: receipt.releaseId, manifest: receipt.manifest,
139
+ digest: parseManifest(receipt.manifest).digest,
140
+ ...deps.publicationUrls?.(artifactId, receipt.releaseId),
141
+ } } };
142
+ }
143
+ if (capability === 'artifacts_get') {
144
+ const artifactId = normalized.artifactId;
145
+ const acl = await visible(artifactId, invocation);
232
146
  const state = await deps.lifecycle.stateOf(artifactId);
233
- if (state === 'deleted' || state === 'purged') {
234
- return { status: 404, body: errorBody(404) };
235
- }
236
147
  const current = await deps.publication.getCurrent(artifactId);
237
- if (current === undefined)
238
- return { status: 404, body: errorBody(404) };
239
- return { status: 200, body: current };
240
- }
241
- if (request.method === 'POST' && ['/withdraw', '/restore', '/delete'].includes(request.path)) {
242
- const artifactId = needString(body['artifactId'], 'artifactId');
243
- await requireVisible(artifactId, request.actorId);
244
- if (request.path === '/withdraw')
245
- await deps.lifecycle.withdraw(artifactId, request.actorId);
246
- if (request.path === '/restore')
247
- await deps.lifecycle.restore(artifactId, request.actorId);
248
- if (request.path === '/delete')
249
- await deps.lifecycle.deleteArtifact(artifactId, request.actorId);
250
- return { status: 200, body: { artifactId, state: await deps.lifecycle.stateOf(artifactId) } };
251
- }
252
- if (request.method === 'POST' && request.path === '/purge') {
253
- const artifactId = needString(body['artifactId'], 'artifactId');
254
- await requireVisible(artifactId, request.actorId);
255
- const digests = [];
256
- for (const releaseId of await deps.lifecycle.listReleases(artifactId)) {
257
- const release = await deps.publication.getRelease(releaseId);
258
- if (release === undefined)
259
- throw new Error(`unknown release: ${releaseId}`);
260
- const manifest = JSON.parse(release.manifest);
261
- for (const file of manifest.files ?? []) {
262
- if (typeof file.digest === 'string')
263
- digests.push(file.digest);
264
- }
265
- }
266
- const report = await deps.lifecycle.purge(artifactId, request.actorId, digests);
267
- return { status: 200, body: report };
268
- }
269
- if (request.method === 'POST' && request.path === '/promote') {
270
- const artifactId = needString(body['artifactId'], 'artifactId');
271
- await requireVisible(artifactId, request.actorId);
272
- const result = await deps.lifecycle.promote(artifactId, request.actorId, needString(body['releaseId'], 'releaseId'), needString(body['expectedCurrent'], 'expectedCurrent'));
273
- return { status: 200, body: result };
274
- }
275
- if (request.method === 'POST' && request.path === '/renew') {
276
- const artifactId = needString(body['artifactId'], 'artifactId');
277
- await requireVisible(artifactId, request.actorId);
278
- const result = await deps.lifecycle.renew(artifactId, request.actorId);
279
- return { status: 200, body: result };
280
- }
281
- if (request.method === 'POST' && request.path === '/fork') {
282
- const artifactId = needString(body['artifactId'], 'artifactId');
283
- // Visibility first (404 without a read right, no oracle); a
284
- // visible object then reports its terminal state (410).
285
- const acl = await requireVisible(artifactId, request.actorId);
148
+ return { ok: true, invocationId: invocation.invocationId, commitState: 'not_committed', replayed: false,
149
+ result: { status: 200, body: { artifactId, ownerId: acl.ownerId, storageScopeId: acl.storageScopeId,
150
+ state, isPublic: acl.isPublic, withdrawn: state === 'withdrawn', currentReleaseId: current?.releaseId,
151
+ ...(acl.ownerId === invocation.actorId ? { grants: acl.grants } : {}) } } };
152
+ }
153
+ const visibleArtifacts = [];
154
+ for (const artifactId of await deps.acls.listArtifactIds()) {
155
+ const acl = await deps.acls.getArtifactAcl(artifactId);
156
+ if (!acl || acl.storageScopeId !== invocation.storageScopeId)
157
+ continue;
286
158
  const state = await deps.lifecycle.stateOf(artifactId);
287
- if (state === 'deleted' || state === 'purged') {
288
- throw new Error(`cannot fork ${state} artifact: ${artifactId}`);
289
- }
290
- const current = await deps.publication.getCurrent(artifactId);
291
- if (current === undefined)
292
- throw new Error(`no release to fork for ${artifactId}`);
293
- const manifest = JSON.parse(current.manifest);
294
- const parent = {
295
- artifactId,
296
- ownerId: acl.ownerId,
297
- releaseId: current.releaseId,
298
- manifest: current.manifest,
299
- state,
300
- readerIds: acl.grants.map((grant) => grant.principalId),
301
- blobDigests: (manifest.files ?? []).flatMap((file) => typeof file.digest === 'string' ? [file.digest] : []),
302
- };
303
- await deps.forks.track(parent);
304
- const forked = await deps.forks.fork({ actorId: request.actorId, artifactId });
305
- return { status: 201, body: forked };
306
- }
307
- if (request.method === 'POST' && request.path === '/access/grant') {
308
- const artifactId = needString(body['artifactId'], 'artifactId');
309
- const acl = await requireVisible(artifactId, request.actorId);
310
- const grant = body['grant'];
311
- if (grant !== 'read' && grant !== 'source')
312
- throw new Error('invalid grant: must be read or source');
313
- const updated = grantAccess(acl, request.actorId, {
314
- principalId: needString(body['principalId'], 'principalId'),
315
- grant,
316
- });
317
- await deps.acls.putArtifactAcl(artifactId, updated);
318
- return { status: 200, body: { grants: updated.grants } };
319
- }
320
- if (request.method === 'POST' && request.path === '/access/revoke') {
321
- const artifactId = needString(body['artifactId'], 'artifactId');
322
- const acl = await requireVisible(artifactId, request.actorId);
323
- const updated = revokeAccess(acl, request.actorId, needString(body['principalId'], 'principalId'));
324
- await deps.acls.putArtifactAcl(artifactId, updated);
325
- return { status: 200, body: { grants: updated.grants } };
326
- }
327
- if (request.method === 'POST' && request.path === '/sources') {
328
- const artifactId = needString(body['artifactId'], 'artifactId');
329
- const acl = await requireVisible(artifactId, request.actorId);
330
- assertArtifactAccess({ actorUserId: request.actorId, artifactOwnerId: acl.ownerId, action: 'mutate' });
331
- const attached = parseSourceRef(body['source'] ?? {});
332
- await deps.sources.putSource(artifactId, attached);
333
- return { status: 200, body: attached };
334
- }
335
- if (request.method === 'GET' && request.path.startsWith('/content/')) {
336
- const rest = request.path.slice('/content/'.length).split('/');
337
- if (rest.length !== 2 || (rest[0]?.length ?? 0) === 0 || (rest[1]?.length ?? 0) === 0) {
338
- return { status: 404, body: errorBody(404) };
339
- }
340
- await requireVisible(rest[0], request.actorId);
341
- const served = await runtime.serveFile({
342
- artifactId: rest[0],
343
- digest: rest[1],
344
- actorId: request.actorId,
345
- isApproved,
346
- });
347
- return {
348
- status: 200,
349
- body: {
350
- digest: served.digest,
351
- mimeType: served.mimeType,
352
- size: served.size,
353
- bytes: Array.from(served.bytes),
354
- },
355
- };
356
- }
357
- if (request.method === 'GET' && request.path.startsWith('/source/')) {
358
- const artifactId = request.path.slice('/source/'.length);
359
- if (artifactId.length === 0)
360
- return { status: 404, body: errorBody(404) };
361
- const acl = await requireVisible(artifactId, request.actorId);
362
- if (!evaluateAccess(acl, request.actorId, 'source', isApproved)) {
363
- throw new Error('access denied: source export requires a source grant');
364
- }
365
- const source = await deps.sources.getSource(artifactId);
366
- if (source === undefined)
367
- return { status: 404, body: errorBody(404) };
368
- return { status: 200, body: source };
369
- }
370
- if (request.method === 'POST' && request.path === '/list') {
371
- const limit = body['limit'] === undefined ? 20 : body['limit'];
372
- const offset = body['offset'] === undefined ? 0 : body['offset'];
373
- if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
374
- throw new Error('invalid request: limit must be an integer in 1..100');
375
- }
376
- if (!Number.isInteger(offset) || offset < 0) {
377
- throw new Error('invalid request: offset must be a non-negative integer');
378
- }
379
- // ACL filtering precedes pagination: invisible artifacts neither
380
- // appear nor shift the page window (spec-v8 5c).
381
- const visible = [];
382
- for (const artifactId of await deps.acls.listArtifactIds()) {
383
- const acl = await deps.acls.getArtifactAcl(artifactId);
384
- if (acl === undefined)
385
- continue;
386
- const state = await deps.lifecycle.stateOf(artifactId);
387
- if (!deps.lifecycle.canRead({ artifactId, state }, acl, request.actorId, 'read', isApproved)) {
388
- continue;
389
- }
390
- visible.push({ artifactId, ownerId: acl.ownerId, state, isPublic: acl.isPublic });
391
- }
392
- return {
393
- status: 200,
394
- body: {
395
- total: visible.length,
396
- artifacts: visible.slice(offset, offset + limit),
397
- },
398
- };
399
- }
400
- if (request.method === 'GET' && request.path.startsWith('/meta/')) {
401
- const artifactId = request.path.slice('/meta/'.length);
402
- if (artifactId.length === 0)
403
- return { status: 404, body: errorBody(404) };
404
- const acl = await requireVisible(artifactId, request.actorId);
405
- const state = await deps.lifecycle.stateOf(artifactId);
406
- const current = await deps.publication.getCurrent(artifactId);
407
- return {
408
- status: 200,
409
- body: {
410
- artifactId,
411
- ownerId: acl.ownerId,
412
- storageScopeId: acl.storageScopeId,
413
- state,
414
- isPublic: acl.isPublic,
415
- withdrawn: state === 'withdrawn',
416
- currentReleaseId: current?.releaseId,
417
- // Grant rosters stay owner-visible; readers learn no principals.
418
- ...(request.actorId === acl.ownerId ? { grants: acl.grants } : {}),
419
- },
420
- };
421
- }
422
- if (request.method === 'POST' && request.path === '/uploads') {
423
- const { uploads } = requireUploads();
424
- const artifactId = needString(body['artifactId'], 'artifactId');
425
- const acl = await requireVisible(artifactId, request.actorId);
426
- assertArtifactAccess({ actorUserId: request.actorId, artifactOwnerId: acl.ownerId, action: 'mutate' });
427
- const maxSize = body['maxSize'];
428
- if (maxSize !== undefined && (!Number.isSafeInteger(maxSize) || maxSize < 1)) {
429
- throw new Error('invalid request: maxSize must be a positive integer');
430
- }
431
- const limits = deps.publication.limits ?? DEFAULT_PUBLICATION_LIMITS;
432
- const begun = await uploads.begin({
433
- mimeType: needString(body['mimeType'], 'mimeType'),
434
- maxSize: Math.min(maxSize ?? Infinity, limits.maxFileBytes, limits.maxTotalBytes),
435
- });
436
- uploadOwners.set(begun.uploadId, { artifactId, ownerId: request.actorId });
437
- return { status: 201, body: begun };
438
- }
439
- if (request.method === 'POST' && request.path === '/uploads/append') {
440
- const { uploads } = requireUploads();
441
- const uploadId = needString(body['uploadId'], 'uploadId');
442
- requireUploadOwner(uploadId, request.actorId);
443
- const offset = body['offset'];
444
- if (!Number.isSafeInteger(offset) || offset < 0) {
445
- throw new Error('invalid request: offset must be a non-negative integer');
446
- }
447
- const limits = deps.publication.limits ?? DEFAULT_PUBLICATION_LIMITS;
448
- const remaining = Math.min(uploads.remainingBytes(uploadId, offset), limits.maxFileBytes - offset, limits.maxTotalBytes - offset);
449
- const appended = await uploads.append(uploadId, offset, needBytes(body['bytes'], remaining));
450
- return { status: 200, body: appended };
451
- }
452
- if (request.method === 'POST' && request.path === '/uploads/commit') {
453
- const { uploads, staging } = requireUploads();
454
- const uploadId = needString(body['uploadId'], 'uploadId');
455
- const binding = requireUploadOwner(uploadId, request.actorId);
456
- const artifactId = needString(body['artifactId'], 'artifactId');
457
- if (artifactId !== binding.artifactId) {
458
- throw new Error('invalid request: upload belongs to another artifact');
459
- }
460
- const acl = await requireVisible(artifactId, request.actorId);
461
- assertArtifactAccess({ actorUserId: request.actorId, artifactOwnerId: acl.ownerId, action: 'mutate' });
462
- if (acl.storageScopeId === undefined)
463
- throw new Error('invalid request: missing storage scope');
464
- const size = body['size'];
465
- if (!Number.isInteger(size) || size < 0) {
466
- throw new Error('invalid request: size must be a non-negative integer');
467
- }
468
- const files = [{
469
- path: needString(body['path'], 'path'),
470
- mimeType: needString(body['mimeType'], 'mimeType'),
471
- bytes: new Uint8Array(0),
472
- }];
473
- await uploads.commit(uploadId, {
474
- digest: needString(body['digest'], 'digest'),
475
- size: size,
476
- }, (bytes) => {
477
- files[0].bytes = bytes;
478
- validatePublicationFiles(files, deps.publication.limits ?? DEFAULT_PUBLICATION_LIMITS);
479
- });
480
- const result = await deps.publication.publish({
481
- ownerId: acl.ownerId,
482
- storageScopeId: acl.storageScopeId,
483
- artifactId,
484
- profile: (body['profile'] ?? {}),
485
- files,
486
- idempotencyKey: typeof body['idempotencyKey'] === 'string' ? body['idempotencyKey'] : undefined,
487
- paramsHash: typeof body['paramsHash'] === 'string' ? body['paramsHash'] : undefined,
488
- });
489
- await deps.lifecycle.trackRelease(artifactId, result.releaseId);
490
- await staging.discard(uploadId);
491
- return { status: 201, body: result };
492
- }
493
- if (request.method === 'GET' && request.path.startsWith('/templates/')) {
494
- const templateId = request.path.slice('/templates/'.length);
495
- if (templateId.length === 0 || templateId.includes('/')) {
496
- return { status: 404, body: errorBody(404) };
497
- }
498
- const template = await requireTemplates().getTemplate(templateId);
499
- if (template === undefined)
500
- return { status: 404, body: errorBody(404) };
501
- return { status: 200, body: template };
502
- }
503
- if (request.method === 'GET' &&
504
- (request.path === '/api-guide' || request.path.startsWith('/api-guide/'))) {
505
- const version = request.path === '/api-guide'
506
- ? undefined
507
- : request.path.slice('/api-guide/'.length);
508
- if (version !== undefined && (version.length === 0 || version.includes('/'))) {
509
- return { status: 404, body: errorBody(404) };
510
- }
511
- const guide = await requireTemplates().getApiGuide(version);
512
- if (guide === undefined)
513
- return { status: 404, body: errorBody(404) };
514
- return { status: 200, body: guide };
515
- }
516
- if (request.method === 'POST' && request.path === '/access/public') {
517
- const artifactId = needString(body['artifactId'], 'artifactId');
518
- const acl = await requireVisible(artifactId, request.actorId);
519
- assertArtifactAccess({ actorUserId: request.actorId, artifactOwnerId: acl.ownerId, action: 'mutate' });
520
- if (typeof body['isPublic'] !== 'boolean') {
521
- throw new Error('invalid request: isPublic must be a boolean');
159
+ if (deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, 'read', deps.isApprovedAccount)) {
160
+ visibleArtifacts.push({ artifactId, ownerId: acl.ownerId, state, isPublic: acl.isPublic });
522
161
  }
523
- await deps.acls.putArtifactAcl(artifactId, { ...acl, isPublic: body['isPublic'] });
524
- return { status: 200, body: { artifactId, isPublic: body['isPublic'] } };
525
162
  }
526
- return { status: 404, body: errorBody(404) };
163
+ const offset = normalized.offset;
164
+ return { ok: true, invocationId: invocation.invocationId, commitState: 'not_committed', replayed: false,
165
+ result: { status: 200, body: { total: visibleArtifacts.length,
166
+ artifacts: visibleArtifacts.slice(offset, offset + normalized.limit) } } };
527
167
  }
528
- catch (error) {
529
- return fail(error);
168
+ catch (cause) {
169
+ const failure = classifyArtifactFailure(cause, { invocationId: invocation?.invocationId ?? '',
170
+ operation, phase, commitState }, (error) => error instanceof ArtifactProfileInputError
171
+ ? new ArtifactOperationError(error.category) : deps.mapHostError?.(error));
172
+ if (cause instanceof CommitQuarantineError) {
173
+ const migration = { ...failure, kind: 'operational', category: 'provider_unavailable',
174
+ variant: 'legacy_quarantine', httpStatus: 503, publicCode: 'capability_unavailable', retriable: false };
175
+ Object.defineProperty(migration, 'cause', { value: cause, enumerable: false });
176
+ return { ok: false, failure: Object.freeze(migration) };
177
+ }
178
+ return { ok: false, failure };
530
179
  }
531
180
  },
181
+ async handle(request, invocation) { return projectArtifactHttpOutcome(await app.execute(request, invocation)); },
532
182
  };
183
+ return Object.freeze(app);
533
184
  }