@sentropic/artifacts 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +86 -2
- package/dist/adapters/postgres.d.ts +35 -0
- package/dist/adapters/postgres.js +301 -0
- package/dist/adapters/s3.d.ts +19 -0
- package/dist/adapters/s3.js +70 -0
- package/dist/client.d.ts +23 -0
- package/dist/client.js +44 -0
- package/dist/codecs.d.ts +12 -0
- package/dist/codecs.js +77 -0
- package/dist/content/api-profile.d.ts +38 -0
- package/dist/content/api-profile.js +40 -0
- package/dist/content/grants.d.ts +59 -0
- package/dist/content/grants.js +134 -0
- package/dist/content/handler.d.ts +34 -0
- package/dist/content/handler.js +127 -0
- package/dist/content/headers.d.ts +25 -0
- package/dist/content/headers.js +56 -0
- package/dist/content/routing.d.ts +13 -0
- package/dist/content/routing.js +59 -0
- package/dist/contracts.d.ts +102 -0
- package/dist/contracts.js +25 -0
- package/dist/http.d.ts +43 -0
- package/dist/http.js +502 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +29 -0
- package/dist/mcp.d.ts +68 -0
- package/dist/mcp.js +293 -0
- package/dist/parameters.d.ts +29 -0
- package/dist/parameters.js +65 -0
- package/dist/provider-definition.d.ts +36 -0
- package/dist/provider-definition.js +45 -0
- package/dist/server/access.d.ts +39 -0
- package/dist/server/access.js +53 -0
- package/dist/server/authorization.d.ts +17 -0
- package/dist/server/authorization.js +12 -0
- package/dist/server/bindings.d.ts +34 -0
- package/dist/server/bindings.js +60 -0
- package/dist/server/fork.d.ts +51 -0
- package/dist/server/fork.js +75 -0
- package/dist/server/gc.d.ts +24 -0
- package/dist/server/gc.js +90 -0
- package/dist/server/idempotency.d.ts +23 -0
- package/dist/server/idempotency.js +38 -0
- package/dist/server/lifecycle.d.ts +57 -0
- package/dist/server/lifecycle.js +184 -0
- package/dist/server/object-intents.d.ts +41 -0
- package/dist/server/object-intents.js +62 -0
- package/dist/server/ports.d.ts +34 -0
- package/dist/server/ports.js +50 -0
- package/dist/server/publication.d.ts +67 -0
- package/dist/server/publication.js +154 -0
- package/dist/server/reader-profile.d.ts +27 -0
- package/dist/server/reader-profile.js +18 -0
- package/dist/server/runtime.d.ts +33 -0
- package/dist/server/runtime.js +44 -0
- package/dist/server/service.d.ts +19 -0
- package/dist/server/service.js +32 -0
- package/dist/server/sources.d.ts +27 -0
- package/dist/server/sources.js +56 -0
- package/dist/server/templates.d.ts +33 -0
- package/dist/server/templates.js +86 -0
- package/dist/server/uploads.d.ts +29 -0
- package/dist/server/uploads.js +86 -0
- package/package.json +162 -4
package/dist/http.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
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 { 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. */
|
|
55
|
+
export function errorBody(status) {
|
|
56
|
+
return { error: ERROR_CODES[status] ?? 'internal_error' };
|
|
57
|
+
}
|
|
58
|
+
function fail(error) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
const status = errorStatus(message);
|
|
61
|
+
return { status, body: errorBody(status) };
|
|
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}`);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
function needBytes(value) {
|
|
75
|
+
if (!Array.isArray(value))
|
|
76
|
+
throw new Error('invalid file bytes: must be an array');
|
|
77
|
+
const bytes = new Uint8Array(value.length);
|
|
78
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
79
|
+
const byte = value[i];
|
|
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;
|
|
84
|
+
}
|
|
85
|
+
return bytes;
|
|
86
|
+
}
|
|
87
|
+
/** Create the HTTP app over injected services (no server, no fetch). */
|
|
88
|
+
export function createHttpArtifactApp(deps) {
|
|
89
|
+
const isApproved = deps.isApprovedAccount ?? (() => false);
|
|
90
|
+
const runtime = createContentRuntime({
|
|
91
|
+
acls: deps.acls,
|
|
92
|
+
lifecycle: deps.lifecycle,
|
|
93
|
+
publication: deps.publication,
|
|
94
|
+
blobs: deps.blobs,
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
97
|
+
* Load the ACL only when the actor may learn the artifact exists: the
|
|
98
|
+
* owner always, everyone else only with a live read right. Unknown and
|
|
99
|
+
* unreadable artifacts throw the identical error (no existence oracle).
|
|
100
|
+
*/
|
|
101
|
+
// Upload sessions are bound to their creating owner + artifact in-app
|
|
102
|
+
// (the upload service itself is actor-agnostic staging).
|
|
103
|
+
const uploadOwners = new Map();
|
|
104
|
+
function requireUploads() {
|
|
105
|
+
if (deps.uploads === undefined || deps.staging === undefined) {
|
|
106
|
+
throw new Error('invalid request: uploads are not configured on this host');
|
|
107
|
+
}
|
|
108
|
+
return { uploads: deps.uploads, staging: deps.staging };
|
|
109
|
+
}
|
|
110
|
+
function requireTemplates() {
|
|
111
|
+
if (deps.templates === undefined) {
|
|
112
|
+
throw new Error('invalid request: templates are not configured on this host');
|
|
113
|
+
}
|
|
114
|
+
return deps.templates;
|
|
115
|
+
}
|
|
116
|
+
function requireUploadOwner(uploadId, actorId) {
|
|
117
|
+
const binding = uploadOwners.get(uploadId);
|
|
118
|
+
if (binding === undefined || binding.ownerId !== actorId) {
|
|
119
|
+
throw new Error(`unknown upload: ${uploadId}`);
|
|
120
|
+
}
|
|
121
|
+
return binding;
|
|
122
|
+
}
|
|
123
|
+
async function requireVisible(artifactId, actorId) {
|
|
124
|
+
const acl = await deps.acls.getArtifactAcl(artifactId);
|
|
125
|
+
if (acl === undefined)
|
|
126
|
+
throw new Error(`unknown artifact: ${artifactId}`);
|
|
127
|
+
if (acl.ownerId === actorId)
|
|
128
|
+
return acl;
|
|
129
|
+
const state = await deps.lifecycle.stateOf(artifactId);
|
|
130
|
+
if (!deps.lifecycle.canRead({ artifactId, state }, acl, actorId, 'read', isApproved)) {
|
|
131
|
+
throw new Error(`unknown artifact: ${artifactId}`);
|
|
132
|
+
}
|
|
133
|
+
return acl;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
async handle(request) {
|
|
137
|
+
try {
|
|
138
|
+
if (request.actorId.length === 0)
|
|
139
|
+
return { status: 401, body: errorBody(401) };
|
|
140
|
+
const body = request.body === undefined ? {} : needObject(request.body);
|
|
141
|
+
if (request.method === 'POST' && request.path === '/ensure') {
|
|
142
|
+
const ensured = buildEnsureRequest(deps.parameterSchema, {
|
|
143
|
+
bindingName: needString(body['bindingName'], 'bindingName'),
|
|
144
|
+
storageScopeId: needString(body['storageScopeId'], 'storageScopeId'),
|
|
145
|
+
idempotencyKey: needString(body['idempotencyKey'], 'idempotencyKey'),
|
|
146
|
+
paramsHash: typeof body['paramsHash'] === 'string' ? body['paramsHash'] : '',
|
|
147
|
+
parameters: (body['parameters'] ?? {}),
|
|
148
|
+
});
|
|
149
|
+
const result = await deps.artifacts.ensurePrivateView({
|
|
150
|
+
actorUserId: request.actorId,
|
|
151
|
+
ownerId: request.actorId,
|
|
152
|
+
storageScopeId: ensured.storageScopeId,
|
|
153
|
+
bindingName: ensured.bindingName,
|
|
154
|
+
idempotencyKey: ensured.idempotencyKey,
|
|
155
|
+
paramsHash: ensured.paramsHash,
|
|
156
|
+
});
|
|
157
|
+
if ((await deps.acls.getArtifactAcl(result.artifactId)) === undefined) {
|
|
158
|
+
await deps.acls.putArtifactAcl(result.artifactId, {
|
|
159
|
+
ownerId: request.actorId,
|
|
160
|
+
storageScopeId: ensured.storageScopeId,
|
|
161
|
+
isPublic: false,
|
|
162
|
+
withdrawn: false,
|
|
163
|
+
grants: [],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
await deps.lifecycle.register(result.artifactId, request.actorId);
|
|
167
|
+
return { status: 201, body: result };
|
|
168
|
+
}
|
|
169
|
+
if (request.method === 'POST' && request.path === '/publish') {
|
|
170
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
171
|
+
const acl = await requireVisible(artifactId, request.actorId);
|
|
172
|
+
assertArtifactAccess({ actorUserId: request.actorId, artifactOwnerId: acl.ownerId, action: 'mutate' });
|
|
173
|
+
if (acl.storageScopeId === undefined)
|
|
174
|
+
throw new Error('invalid request: missing storage scope');
|
|
175
|
+
const rawFiles = body['files'];
|
|
176
|
+
if (!Array.isArray(rawFiles))
|
|
177
|
+
throw new Error('invalid request: missing files');
|
|
178
|
+
const files = rawFiles.map((file) => {
|
|
179
|
+
const entry = needObject(file);
|
|
180
|
+
return {
|
|
181
|
+
path: needString(entry['path'], 'file.path'),
|
|
182
|
+
mimeType: needString(entry['mimeType'], 'file.mimeType'),
|
|
183
|
+
bytes: needBytes(entry['bytes']),
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
const source = body['source'];
|
|
187
|
+
const attached = source === undefined ? undefined : parseSourceRef(source);
|
|
188
|
+
const result = await deps.publication.publish({
|
|
189
|
+
ownerId: acl.ownerId,
|
|
190
|
+
storageScopeId: acl.storageScopeId,
|
|
191
|
+
artifactId,
|
|
192
|
+
profile: (body['profile'] ?? {}),
|
|
193
|
+
files,
|
|
194
|
+
idempotencyKey: typeof body['idempotencyKey'] === 'string' ? body['idempotencyKey'] : undefined,
|
|
195
|
+
paramsHash: typeof body['paramsHash'] === 'string' ? body['paramsHash'] : undefined,
|
|
196
|
+
});
|
|
197
|
+
if (attached !== undefined)
|
|
198
|
+
await deps.sources.putSource(artifactId, attached);
|
|
199
|
+
await deps.lifecycle.trackRelease(artifactId, result.releaseId);
|
|
200
|
+
return { status: 201, body: result };
|
|
201
|
+
}
|
|
202
|
+
if (request.method === 'GET' && request.path.startsWith('/current/')) {
|
|
203
|
+
const artifactId = request.path.slice('/current/'.length);
|
|
204
|
+
if (artifactId.length === 0)
|
|
205
|
+
return { status: 404, body: errorBody(404) };
|
|
206
|
+
await requireVisible(artifactId, request.actorId);
|
|
207
|
+
const state = await deps.lifecycle.stateOf(artifactId);
|
|
208
|
+
if (state === 'deleted' || state === 'purged') {
|
|
209
|
+
return { status: 404, body: errorBody(404) };
|
|
210
|
+
}
|
|
211
|
+
const current = await deps.publication.getCurrent(artifactId);
|
|
212
|
+
if (current === undefined)
|
|
213
|
+
return { status: 404, body: errorBody(404) };
|
|
214
|
+
return { status: 200, body: current };
|
|
215
|
+
}
|
|
216
|
+
if (request.method === 'POST' && ['/withdraw', '/restore', '/delete'].includes(request.path)) {
|
|
217
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
218
|
+
await requireVisible(artifactId, request.actorId);
|
|
219
|
+
if (request.path === '/withdraw')
|
|
220
|
+
await deps.lifecycle.withdraw(artifactId, request.actorId);
|
|
221
|
+
if (request.path === '/restore')
|
|
222
|
+
await deps.lifecycle.restore(artifactId, request.actorId);
|
|
223
|
+
if (request.path === '/delete')
|
|
224
|
+
await deps.lifecycle.deleteArtifact(artifactId, request.actorId);
|
|
225
|
+
return { status: 200, body: { artifactId, state: await deps.lifecycle.stateOf(artifactId) } };
|
|
226
|
+
}
|
|
227
|
+
if (request.method === 'POST' && request.path === '/purge') {
|
|
228
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
229
|
+
await requireVisible(artifactId, request.actorId);
|
|
230
|
+
const digests = [];
|
|
231
|
+
for (const releaseId of await deps.lifecycle.listReleases(artifactId)) {
|
|
232
|
+
const release = await deps.publication.getRelease(releaseId);
|
|
233
|
+
if (release === undefined)
|
|
234
|
+
throw new Error(`unknown release: ${releaseId}`);
|
|
235
|
+
const manifest = JSON.parse(release.manifest);
|
|
236
|
+
for (const file of manifest.files ?? []) {
|
|
237
|
+
if (typeof file.digest === 'string')
|
|
238
|
+
digests.push(file.digest);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const report = await deps.lifecycle.purge(artifactId, request.actorId, digests);
|
|
242
|
+
return { status: 200, body: report };
|
|
243
|
+
}
|
|
244
|
+
if (request.method === 'POST' && request.path === '/promote') {
|
|
245
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
246
|
+
await requireVisible(artifactId, request.actorId);
|
|
247
|
+
const result = await deps.lifecycle.promote(artifactId, request.actorId, needString(body['releaseId'], 'releaseId'), needString(body['expectedCurrent'], 'expectedCurrent'));
|
|
248
|
+
return { status: 200, body: result };
|
|
249
|
+
}
|
|
250
|
+
if (request.method === 'POST' && request.path === '/renew') {
|
|
251
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
252
|
+
await requireVisible(artifactId, request.actorId);
|
|
253
|
+
const result = await deps.lifecycle.renew(artifactId, request.actorId);
|
|
254
|
+
return { status: 200, body: result };
|
|
255
|
+
}
|
|
256
|
+
if (request.method === 'POST' && request.path === '/fork') {
|
|
257
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
258
|
+
// Visibility first (404 without a read right, no oracle); a
|
|
259
|
+
// visible object then reports its terminal state (410).
|
|
260
|
+
const acl = await requireVisible(artifactId, request.actorId);
|
|
261
|
+
const state = await deps.lifecycle.stateOf(artifactId);
|
|
262
|
+
if (state === 'deleted' || state === 'purged') {
|
|
263
|
+
throw new Error(`cannot fork ${state} artifact: ${artifactId}`);
|
|
264
|
+
}
|
|
265
|
+
const current = await deps.publication.getCurrent(artifactId);
|
|
266
|
+
if (current === undefined)
|
|
267
|
+
throw new Error(`no release to fork for ${artifactId}`);
|
|
268
|
+
const manifest = JSON.parse(current.manifest);
|
|
269
|
+
const parent = {
|
|
270
|
+
artifactId,
|
|
271
|
+
ownerId: acl.ownerId,
|
|
272
|
+
releaseId: current.releaseId,
|
|
273
|
+
manifest: current.manifest,
|
|
274
|
+
state,
|
|
275
|
+
readerIds: acl.grants.map((grant) => grant.principalId),
|
|
276
|
+
blobDigests: (manifest.files ?? []).flatMap((file) => typeof file.digest === 'string' ? [file.digest] : []),
|
|
277
|
+
};
|
|
278
|
+
await deps.forks.track(parent);
|
|
279
|
+
const forked = await deps.forks.fork({ actorId: request.actorId, artifactId });
|
|
280
|
+
return { status: 201, body: forked };
|
|
281
|
+
}
|
|
282
|
+
if (request.method === 'POST' && request.path === '/access/grant') {
|
|
283
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
284
|
+
const acl = await requireVisible(artifactId, request.actorId);
|
|
285
|
+
const grant = body['grant'];
|
|
286
|
+
if (grant !== 'read' && grant !== 'source')
|
|
287
|
+
throw new Error('invalid grant: must be read or source');
|
|
288
|
+
const updated = grantAccess(acl, request.actorId, {
|
|
289
|
+
principalId: needString(body['principalId'], 'principalId'),
|
|
290
|
+
grant,
|
|
291
|
+
});
|
|
292
|
+
await deps.acls.putArtifactAcl(artifactId, updated);
|
|
293
|
+
return { status: 200, body: { grants: updated.grants } };
|
|
294
|
+
}
|
|
295
|
+
if (request.method === 'POST' && request.path === '/access/revoke') {
|
|
296
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
297
|
+
const acl = await requireVisible(artifactId, request.actorId);
|
|
298
|
+
const updated = revokeAccess(acl, request.actorId, needString(body['principalId'], 'principalId'));
|
|
299
|
+
await deps.acls.putArtifactAcl(artifactId, updated);
|
|
300
|
+
return { status: 200, body: { grants: updated.grants } };
|
|
301
|
+
}
|
|
302
|
+
if (request.method === 'POST' && request.path === '/sources') {
|
|
303
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
304
|
+
const acl = await requireVisible(artifactId, request.actorId);
|
|
305
|
+
assertArtifactAccess({ actorUserId: request.actorId, artifactOwnerId: acl.ownerId, action: 'mutate' });
|
|
306
|
+
const attached = parseSourceRef(body['source'] ?? {});
|
|
307
|
+
await deps.sources.putSource(artifactId, attached);
|
|
308
|
+
return { status: 200, body: attached };
|
|
309
|
+
}
|
|
310
|
+
if (request.method === 'GET' && request.path.startsWith('/content/')) {
|
|
311
|
+
const rest = request.path.slice('/content/'.length).split('/');
|
|
312
|
+
if (rest.length !== 2 || (rest[0]?.length ?? 0) === 0 || (rest[1]?.length ?? 0) === 0) {
|
|
313
|
+
return { status: 404, body: errorBody(404) };
|
|
314
|
+
}
|
|
315
|
+
await requireVisible(rest[0], request.actorId);
|
|
316
|
+
const served = await runtime.serveFile({
|
|
317
|
+
artifactId: rest[0],
|
|
318
|
+
digest: rest[1],
|
|
319
|
+
actorId: request.actorId,
|
|
320
|
+
isApproved,
|
|
321
|
+
});
|
|
322
|
+
return {
|
|
323
|
+
status: 200,
|
|
324
|
+
body: {
|
|
325
|
+
digest: served.digest,
|
|
326
|
+
mimeType: served.mimeType,
|
|
327
|
+
size: served.size,
|
|
328
|
+
bytes: Array.from(served.bytes),
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (request.method === 'GET' && request.path.startsWith('/source/')) {
|
|
333
|
+
const artifactId = request.path.slice('/source/'.length);
|
|
334
|
+
if (artifactId.length === 0)
|
|
335
|
+
return { status: 404, body: errorBody(404) };
|
|
336
|
+
const acl = await requireVisible(artifactId, request.actorId);
|
|
337
|
+
if (!evaluateAccess(acl, request.actorId, 'source', isApproved)) {
|
|
338
|
+
throw new Error('access denied: source export requires a source grant');
|
|
339
|
+
}
|
|
340
|
+
const source = await deps.sources.getSource(artifactId);
|
|
341
|
+
if (source === undefined)
|
|
342
|
+
return { status: 404, body: errorBody(404) };
|
|
343
|
+
return { status: 200, body: source };
|
|
344
|
+
}
|
|
345
|
+
if (request.method === 'POST' && request.path === '/list') {
|
|
346
|
+
const limit = body['limit'] === undefined ? 20 : body['limit'];
|
|
347
|
+
const offset = body['offset'] === undefined ? 0 : body['offset'];
|
|
348
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
349
|
+
throw new Error('invalid request: limit must be an integer in 1..100');
|
|
350
|
+
}
|
|
351
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
352
|
+
throw new Error('invalid request: offset must be a non-negative integer');
|
|
353
|
+
}
|
|
354
|
+
// ACL filtering precedes pagination: invisible artifacts neither
|
|
355
|
+
// appear nor shift the page window (spec-v8 5c).
|
|
356
|
+
const visible = [];
|
|
357
|
+
for (const artifactId of await deps.acls.listArtifactIds()) {
|
|
358
|
+
const acl = await deps.acls.getArtifactAcl(artifactId);
|
|
359
|
+
if (acl === undefined)
|
|
360
|
+
continue;
|
|
361
|
+
const state = await deps.lifecycle.stateOf(artifactId);
|
|
362
|
+
if (!deps.lifecycle.canRead({ artifactId, state }, acl, request.actorId, 'read', isApproved)) {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
visible.push({ artifactId, ownerId: acl.ownerId, state, isPublic: acl.isPublic });
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
status: 200,
|
|
369
|
+
body: {
|
|
370
|
+
total: visible.length,
|
|
371
|
+
artifacts: visible.slice(offset, offset + limit),
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
if (request.method === 'GET' && request.path.startsWith('/meta/')) {
|
|
376
|
+
const artifactId = request.path.slice('/meta/'.length);
|
|
377
|
+
if (artifactId.length === 0)
|
|
378
|
+
return { status: 404, body: errorBody(404) };
|
|
379
|
+
const acl = await requireVisible(artifactId, request.actorId);
|
|
380
|
+
const state = await deps.lifecycle.stateOf(artifactId);
|
|
381
|
+
const current = await deps.publication.getCurrent(artifactId);
|
|
382
|
+
return {
|
|
383
|
+
status: 200,
|
|
384
|
+
body: {
|
|
385
|
+
artifactId,
|
|
386
|
+
ownerId: acl.ownerId,
|
|
387
|
+
storageScopeId: acl.storageScopeId,
|
|
388
|
+
state,
|
|
389
|
+
isPublic: acl.isPublic,
|
|
390
|
+
withdrawn: state === 'withdrawn',
|
|
391
|
+
currentReleaseId: current?.releaseId,
|
|
392
|
+
// Grant rosters stay owner-visible; readers learn no principals.
|
|
393
|
+
...(request.actorId === acl.ownerId ? { grants: acl.grants } : {}),
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
if (request.method === 'POST' && request.path === '/uploads') {
|
|
398
|
+
const { uploads } = requireUploads();
|
|
399
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
400
|
+
const acl = await requireVisible(artifactId, request.actorId);
|
|
401
|
+
assertArtifactAccess({ actorUserId: request.actorId, artifactOwnerId: acl.ownerId, action: 'mutate' });
|
|
402
|
+
const maxSize = body['maxSize'];
|
|
403
|
+
if (maxSize !== undefined && (!Number.isInteger(maxSize) || maxSize < 1)) {
|
|
404
|
+
throw new Error('invalid request: maxSize must be a positive integer');
|
|
405
|
+
}
|
|
406
|
+
const begun = await uploads.begin({
|
|
407
|
+
mimeType: needString(body['mimeType'], 'mimeType'),
|
|
408
|
+
maxSize: maxSize,
|
|
409
|
+
});
|
|
410
|
+
uploadOwners.set(begun.uploadId, { artifactId, ownerId: request.actorId });
|
|
411
|
+
return { status: 201, body: begun };
|
|
412
|
+
}
|
|
413
|
+
if (request.method === 'POST' && request.path === '/uploads/append') {
|
|
414
|
+
const { uploads } = requireUploads();
|
|
415
|
+
const uploadId = needString(body['uploadId'], 'uploadId');
|
|
416
|
+
requireUploadOwner(uploadId, request.actorId);
|
|
417
|
+
const offset = body['offset'];
|
|
418
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
419
|
+
throw new Error('invalid request: offset must be a non-negative integer');
|
|
420
|
+
}
|
|
421
|
+
const appended = await uploads.append(uploadId, offset, needBytes(body['bytes']));
|
|
422
|
+
return { status: 200, body: appended };
|
|
423
|
+
}
|
|
424
|
+
if (request.method === 'POST' && request.path === '/uploads/commit') {
|
|
425
|
+
const { uploads, staging } = requireUploads();
|
|
426
|
+
const uploadId = needString(body['uploadId'], 'uploadId');
|
|
427
|
+
const binding = requireUploadOwner(uploadId, request.actorId);
|
|
428
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
429
|
+
if (artifactId !== binding.artifactId) {
|
|
430
|
+
throw new Error('invalid request: upload belongs to another artifact');
|
|
431
|
+
}
|
|
432
|
+
const acl = await requireVisible(artifactId, request.actorId);
|
|
433
|
+
assertArtifactAccess({ actorUserId: request.actorId, artifactOwnerId: acl.ownerId, action: 'mutate' });
|
|
434
|
+
if (acl.storageScopeId === undefined)
|
|
435
|
+
throw new Error('invalid request: missing storage scope');
|
|
436
|
+
const size = body['size'];
|
|
437
|
+
if (!Number.isInteger(size) || size < 0) {
|
|
438
|
+
throw new Error('invalid request: size must be a non-negative integer');
|
|
439
|
+
}
|
|
440
|
+
await uploads.commit(uploadId, {
|
|
441
|
+
digest: needString(body['digest'], 'digest'),
|
|
442
|
+
size: size,
|
|
443
|
+
});
|
|
444
|
+
const bytes = await staging.read(uploadId);
|
|
445
|
+
const result = await deps.publication.publish({
|
|
446
|
+
ownerId: acl.ownerId,
|
|
447
|
+
storageScopeId: acl.storageScopeId,
|
|
448
|
+
artifactId,
|
|
449
|
+
profile: (body['profile'] ?? {}),
|
|
450
|
+
files: [{
|
|
451
|
+
path: needString(body['path'], 'path'),
|
|
452
|
+
mimeType: needString(body['mimeType'], 'mimeType'),
|
|
453
|
+
bytes,
|
|
454
|
+
}],
|
|
455
|
+
idempotencyKey: typeof body['idempotencyKey'] === 'string' ? body['idempotencyKey'] : undefined,
|
|
456
|
+
paramsHash: typeof body['paramsHash'] === 'string' ? body['paramsHash'] : undefined,
|
|
457
|
+
});
|
|
458
|
+
await deps.lifecycle.trackRelease(artifactId, result.releaseId);
|
|
459
|
+
await staging.discard(uploadId);
|
|
460
|
+
return { status: 201, body: result };
|
|
461
|
+
}
|
|
462
|
+
if (request.method === 'GET' && request.path.startsWith('/templates/')) {
|
|
463
|
+
const templateId = request.path.slice('/templates/'.length);
|
|
464
|
+
if (templateId.length === 0 || templateId.includes('/')) {
|
|
465
|
+
return { status: 404, body: errorBody(404) };
|
|
466
|
+
}
|
|
467
|
+
const template = await requireTemplates().getTemplate(templateId);
|
|
468
|
+
if (template === undefined)
|
|
469
|
+
return { status: 404, body: errorBody(404) };
|
|
470
|
+
return { status: 200, body: template };
|
|
471
|
+
}
|
|
472
|
+
if (request.method === 'GET' &&
|
|
473
|
+
(request.path === '/api-guide' || request.path.startsWith('/api-guide/'))) {
|
|
474
|
+
const version = request.path === '/api-guide'
|
|
475
|
+
? undefined
|
|
476
|
+
: request.path.slice('/api-guide/'.length);
|
|
477
|
+
if (version !== undefined && (version.length === 0 || version.includes('/'))) {
|
|
478
|
+
return { status: 404, body: errorBody(404) };
|
|
479
|
+
}
|
|
480
|
+
const guide = await requireTemplates().getApiGuide(version);
|
|
481
|
+
if (guide === undefined)
|
|
482
|
+
return { status: 404, body: errorBody(404) };
|
|
483
|
+
return { status: 200, body: guide };
|
|
484
|
+
}
|
|
485
|
+
if (request.method === 'POST' && request.path === '/access/public') {
|
|
486
|
+
const artifactId = needString(body['artifactId'], 'artifactId');
|
|
487
|
+
const acl = await requireVisible(artifactId, request.actorId);
|
|
488
|
+
assertArtifactAccess({ actorUserId: request.actorId, artifactOwnerId: acl.ownerId, action: 'mutate' });
|
|
489
|
+
if (typeof body['isPublic'] !== 'boolean') {
|
|
490
|
+
throw new Error('invalid request: isPublic must be a boolean');
|
|
491
|
+
}
|
|
492
|
+
await deps.acls.putArtifactAcl(artifactId, { ...acl, isPublic: body['isPublic'] });
|
|
493
|
+
return { status: 200, body: { artifactId, isPublic: body['isPublic'] } };
|
|
494
|
+
}
|
|
495
|
+
return { status: 404, body: errorBody(404) };
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
return fail(error);
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export * from './contracts.js';
|
|
2
|
+
export * from './codecs.js';
|
|
3
|
+
export * from './parameters.js';
|
|
4
|
+
export * from './provider-definition.js';
|
|
5
|
+
export * from './client.js';
|
|
6
|
+
export * from './server/ports.js';
|
|
7
|
+
export * from './server/service.js';
|
|
8
|
+
export * from './server/authorization.js';
|
|
9
|
+
export * from './server/bindings.js';
|
|
10
|
+
export * from './server/idempotency.js';
|
|
11
|
+
export * from './server/publication.js';
|
|
12
|
+
export * from './server/sources.js';
|
|
13
|
+
export * from './server/fork.js';
|
|
14
|
+
export * from './server/lifecycle.js';
|
|
15
|
+
export * from './server/gc.js';
|
|
16
|
+
export * from './server/templates.js';
|
|
17
|
+
export * from './mcp.js';
|
|
18
|
+
export * from './content/grants.js';
|
|
19
|
+
export * from './content/handler.js';
|
|
20
|
+
export * from './content/routing.js';
|
|
21
|
+
export * from './content/headers.js';
|
|
22
|
+
export * from './content/api-profile.js';
|
|
23
|
+
export * from './server/runtime.js';
|
|
24
|
+
export * from './server/reader-profile.js';
|
|
25
|
+
export * from './http.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export * from './contracts.js';
|
|
2
|
+
export * from './codecs.js';
|
|
3
|
+
export * from './parameters.js';
|
|
4
|
+
export * from './provider-definition.js';
|
|
5
|
+
export * from './client.js';
|
|
6
|
+
export * from './server/ports.js';
|
|
7
|
+
export * from './server/service.js';
|
|
8
|
+
export * from './server/authorization.js';
|
|
9
|
+
export * from './server/bindings.js';
|
|
10
|
+
export * from './server/idempotency.js';
|
|
11
|
+
// object-intents, uploads and access define operational types (ObjectIntent,
|
|
12
|
+
// BlobRef, AccessGrant) that extend the stored records of the same name in
|
|
13
|
+
// contracts.ts — import them from their subpaths (see package.json exports),
|
|
14
|
+
// never from this barrel, to keep both meanings unambiguous.
|
|
15
|
+
export * from './server/publication.js';
|
|
16
|
+
export * from './server/sources.js';
|
|
17
|
+
export * from './server/fork.js';
|
|
18
|
+
export * from './server/lifecycle.js';
|
|
19
|
+
export * from './server/gc.js';
|
|
20
|
+
export * from './server/templates.js';
|
|
21
|
+
export * from './mcp.js';
|
|
22
|
+
export * from './content/grants.js';
|
|
23
|
+
export * from './content/handler.js';
|
|
24
|
+
export * from './content/routing.js';
|
|
25
|
+
export * from './content/headers.js';
|
|
26
|
+
export * from './content/api-profile.js';
|
|
27
|
+
export * from './server/runtime.js';
|
|
28
|
+
export * from './server/reader-profile.js';
|
|
29
|
+
export * from './http.js';
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L-5e — reusable artifacts MCP provider (spec-v8 §9 5e, review B3).
|
|
3
|
+
*
|
|
4
|
+
* Part 1: identity, the 15 exact `artifacts_*` tool specs with closed input
|
|
5
|
+
* schemas, and the platform manifest. The logical provider ID is
|
|
6
|
+
* `sentropic.artifacts`; the generic provider hardcodes no application
|
|
7
|
+
* OAuth vocabulary — a host binds its invocation scope through the
|
|
8
|
+
* manifest factory before digests freeze (§3.5). Part 2 adds the tool
|
|
9
|
+
* executor over the same HTTP app direct calls use; part 3 adds the
|
|
10
|
+
* platform adapter and the kernel registry definition (§3).
|
|
11
|
+
*/
|
|
12
|
+
import type { AppConnectorProviderAdapter, AppMcpProviderManifest, CapabilityTool } from '@sentropic/mcp-platform';
|
|
13
|
+
import type { ProviderParameterSchema, RegistryDefinition } from '@sentropic/mcp-platform/registry';
|
|
14
|
+
import type { HttpRequest, HttpResponse } from './http.js';
|
|
15
|
+
export declare const ARTIFACTS_PROVIDER_ID = "sentropic.artifacts";
|
|
16
|
+
export declare const ARTIFACTS_APP_ID = "sentropic-artifacts";
|
|
17
|
+
export declare const ARTIFACTS_PROVIDER_VERSION = "0.1.0";
|
|
18
|
+
export declare const ARTIFACTS_TOOL_NAMES: readonly ["artifacts_get_template", "artifacts_get_api_guide", "artifacts_ensure", "artifacts_publish", "artifacts_create_upload", "artifacts_commit_upload", "artifacts_list", "artifacts_get", "artifacts_get_source", "artifacts_fork", "artifacts_set_access", "artifacts_promote", "artifacts_renew", "artifacts_delete", "artifacts_restore"];
|
|
19
|
+
export type ArtifactsToolName = (typeof ARTIFACTS_TOOL_NAMES)[number];
|
|
20
|
+
type Schema = Record<string, unknown>;
|
|
21
|
+
interface ToolSpec {
|
|
22
|
+
name: ArtifactsToolName;
|
|
23
|
+
description: string;
|
|
24
|
+
inputSchema: Schema;
|
|
25
|
+
mutability: CapabilityTool['mutability'];
|
|
26
|
+
idempotent?: boolean;
|
|
27
|
+
confirm?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare const ARTIFACTS_TOOL_SPECS: readonly ToolSpec[];
|
|
30
|
+
/** Kernel provider parameter schema (typed non-secret host settings). */
|
|
31
|
+
export declare const ARTIFACTS_PARAMETER_SCHEMA: ProviderParameterSchema;
|
|
32
|
+
export interface ArtifactsManifestOptions {
|
|
33
|
+
/** Host authorization profile: scope bound to every tool invocation. */
|
|
34
|
+
invocationScope?: string;
|
|
35
|
+
displayName?: string;
|
|
36
|
+
}
|
|
37
|
+
/** Build the sentropic.artifacts manifest (host scope bound pre-freeze). */
|
|
38
|
+
export declare function createArtifactsManifest(options?: ArtifactsManifestOptions): AppMcpProviderManifest;
|
|
39
|
+
export interface ArtifactsApp {
|
|
40
|
+
handle(request: HttpRequest): Promise<HttpResponse>;
|
|
41
|
+
}
|
|
42
|
+
export interface ArtifactsInvokeInput {
|
|
43
|
+
tool: string;
|
|
44
|
+
actorId: string;
|
|
45
|
+
args: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
/** Create the 15-tool executor over any conforming artifacts HTTP app. */
|
|
48
|
+
export declare function createArtifactsToolExecutor(deps: {
|
|
49
|
+
app: ArtifactsApp;
|
|
50
|
+
defaultStorageScopeId?: string;
|
|
51
|
+
}): {
|
|
52
|
+
invoke(input: ArtifactsInvokeInput): Promise<HttpResponse>;
|
|
53
|
+
};
|
|
54
|
+
export interface ArtifactsAdapterOptions extends ArtifactsManifestOptions {
|
|
55
|
+
app: ArtifactsApp;
|
|
56
|
+
defaultStorageScopeId?: string;
|
|
57
|
+
}
|
|
58
|
+
/** Create the platform adapter driving the 15 tools over the shared app. */
|
|
59
|
+
export declare function createArtifactsAdapter(options: ArtifactsAdapterOptions): AppConnectorProviderAdapter;
|
|
60
|
+
export interface ArtifactsDefinitionOptions extends ArtifactsAdapterOptions {
|
|
61
|
+
/** Local config-review reference (integrity metadata, never authority). */
|
|
62
|
+
approvalRef?: string;
|
|
63
|
+
revision?: string;
|
|
64
|
+
reviewedBy?: string[];
|
|
65
|
+
}
|
|
66
|
+
/** Build the kernel registry definition for sentropic.artifacts (§3). */
|
|
67
|
+
export declare function createArtifactsRegistryDefinition(options: ArtifactsDefinitionOptions): RegistryDefinition;
|
|
68
|
+
export {};
|