@sentropic/artifacts 0.1.4 → 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 +17 -0
- 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/http.d.ts +41 -30
- package/dist/http.js +168 -507
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp-core.d.ts +29 -39
- package/dist/mcp-core.js +83 -244
- package/dist/mcp.js +5 -5
- 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.d.ts +76 -0
- package/dist/server/publication.js +270 -31
- package/dist/server/service.d.ts +60 -0
- package/dist/server/service.js +181 -0
- 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 +27 -1
- package/src/contracts.ts +125 -0
package/dist/http.js
CHANGED
|
@@ -1,533 +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 { 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:
|
|
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' };
|
|
59
16
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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;
|
|
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');
|
|
88
30
|
}
|
|
89
|
-
return bytes;
|
|
90
31
|
}
|
|
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');
|
|
102
|
-
}
|
|
103
|
-
if (Buffer.byteLength(entry['text'], 'utf8') > maxBytes)
|
|
104
|
-
throw new Error('invalid publication byte budget');
|
|
105
|
-
return new TextEncoder().encode(entry['text']);
|
|
106
|
-
}
|
|
107
|
-
/** Create the HTTP app over injected services (no server, no fetch). */
|
|
108
32
|
export function createHttpArtifactApp(deps) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 };
|
|
129
|
-
}
|
|
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;
|
|
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');
|
|
135
36
|
}
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
throw new
|
|
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',
|
|
151
|
-
throw new
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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);
|
|
232
|
-
const state = await deps.lifecycle.stateOf(artifactId);
|
|
233
|
-
if (state === 'deleted' || state === 'purged') {
|
|
234
|
-
return { status: 404, body: errorBody(404) };
|
|
235
|
-
}
|
|
236
|
-
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);
|
|
286
|
-
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] : []),
|
|
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)),
|
|
302
123
|
};
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
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
|
+
})),
|
|
316
136
|
});
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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);
|
|
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);
|
|
405
156
|
const state = await deps.lifecycle.stateOf(artifactId);
|
|
406
157
|
const current = await deps.publication.getCurrent(artifactId);
|
|
407
|
-
return {
|
|
408
|
-
status: 200,
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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');
|
|
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 });
|
|
522
171
|
}
|
|
523
|
-
await deps.acls.putArtifactAcl(artifactId, { ...acl, isPublic: body['isPublic'] });
|
|
524
|
-
return { status: 200, body: { artifactId, isPublic: body['isPublic'] } };
|
|
525
172
|
}
|
|
526
|
-
|
|
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) } } };
|
|
527
177
|
}
|
|
528
|
-
catch (
|
|
529
|
-
|
|
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 };
|
|
530
189
|
}
|
|
531
190
|
},
|
|
191
|
+
async handle(request, invocation) { return projectArtifactHttpOutcome(await app.execute(request, invocation)); },
|
|
532
192
|
};
|
|
193
|
+
return Object.freeze(app);
|
|
533
194
|
}
|