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