@le-space/rootfs 0.6.1 → 0.6.3
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 +4 -0
- package/package.json +1 -1
- package/reference/ucan-store/README.md +89 -0
- package/reference/ucan-store/contract.json +46 -0
- package/reference/ucan-store/rootfs/Dockerfile.rootfs +24 -0
- package/reference/ucan-store/rootfs/README.md +84 -0
- package/reference/ucan-store/rootfs/build-rootfs-image.sh +127 -0
- package/reference/ucan-store/rootfs/build-rootfs.sh +242 -0
- package/reference/ucan-store/rootfs/read-rootfs-contract.py +72 -0
- package/reference/ucan-store/rootfs/ucan-store-autotls-refresh.service +12 -0
- package/reference/ucan-store/rootfs/ucan-store-bootstrap-verify.mjs +409 -0
- package/reference/ucan-store/rootfs/ucan-store-bootstrap.service +21 -0
- package/reference/ucan-store/rootfs/ucan-store-bootstrap.sh +155 -0
- package/reference/ucan-store/rootfs/ucan-store-configure.sh +261 -0
- package/reference/ucan-store/rootfs/ucan-store-describe.py +150 -0
- package/reference/ucan-store/rootfs/ucan-store-request-guard.mjs +740 -0
- package/reference/ucan-store/rootfs/ucan-store-service-start.sh +158 -0
- package/reference/ucan-store/rootfs/ucan-store-setup-server.py +229 -0
- package/reference/ucan-store/rootfs/ucan-store-upload-api-context.js +108 -0
- package/reference/ucan-store/rootfs/ucan-store-upload-api-service-identity.js +116 -0
- package/reference/ucan-store/rootfs/ucan-store.service +19 -0
- package/reference/ucan-store/rootfs/ucan_store_bootstrap_validate.py +226 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_WEB_ROOT = process.env.WEB_ROOT || '/opt/ucan-store/web';
|
|
8
|
+
const DEFAULT_BIND_HOST = process.env.UCAN_STORE_PROXY_HOST || '127.0.0.1';
|
|
9
|
+
const DEFAULT_BIND_PORT = Number.parseInt(process.env.UCAN_STORE_PROXY_PORT || '8788', 10);
|
|
10
|
+
const DEFAULT_UPSTREAM_PORT = Number.parseInt(process.env.STORACHA_LOCAL_PORT || '8787', 10);
|
|
11
|
+
const DEFAULT_BOOTSTRAP_PACKAGE_FILE =
|
|
12
|
+
process.env.UCAN_STORE_BOOTSTRAP_PACKAGE_FILE || '/etc/ucan-store/bootstrap-package.json';
|
|
13
|
+
const DEFAULT_REQUIRE_BOOTSTRAP =
|
|
14
|
+
process.env.UCAN_STORE_REQUIRE_BOOTSTRAP_PACKAGE || '1';
|
|
15
|
+
const DEFAULT_ADMIN_API_TOKEN = process.env.UCAN_STORE_ADMIN_API_TOKEN || '';
|
|
16
|
+
const DEFAULT_SERVICE_SIGNER_FILE =
|
|
17
|
+
process.env.UCAN_STORE_SERVICE_SIGNER_FILE || '/var/lib/ucan-store/service-ed25519.key';
|
|
18
|
+
const DEFAULT_SERVICE_DID =
|
|
19
|
+
process.env.UCAN_STORE_SERVICE_DID || process.env.PUBLIC_UPLOAD_SERVICE_DID || '';
|
|
20
|
+
const DEFAULT_UPLOAD_SERVICE_URL =
|
|
21
|
+
process.env.PUBLIC_UPLOAD_SERVICE_URL || '';
|
|
22
|
+
const DEFAULT_REVOCATION_URL =
|
|
23
|
+
process.env.PUBLIC_REVOCATION_URL || '';
|
|
24
|
+
const DEFAULT_RECEIPTS_URL =
|
|
25
|
+
process.env.PUBLIC_RECEIPTS_URL || '';
|
|
26
|
+
|
|
27
|
+
function printHelp() {
|
|
28
|
+
console.log(`Usage:
|
|
29
|
+
ucan-store-request-guard.mjs
|
|
30
|
+
|
|
31
|
+
Environment:
|
|
32
|
+
UCAN_STORE_PROXY_HOST Bind host (default: 127.0.0.1)
|
|
33
|
+
UCAN_STORE_PROXY_PORT Bind port (default: 8788)
|
|
34
|
+
STORACHA_LOCAL_PORT Upstream upload-service port (default: 8787)
|
|
35
|
+
UCAN_STORE_BOOTSTRAP_PACKAGE_FILE Bootstrap package path
|
|
36
|
+
UCAN_STORE_REQUIRE_BOOTSTRAP_PACKAGE Enforce bootstrap package presence (default: 1)
|
|
37
|
+
UCAN_STORE_ADMIN_API_TOKEN Bearer token for /admin/delegations
|
|
38
|
+
UCAN_STORE_SERVICE_SIGNER_FILE Persisted service signer path
|
|
39
|
+
UCAN_STORE_SERVICE_DID Explicit service DID alias for delegated proofs
|
|
40
|
+
PUBLIC_UPLOAD_SERVICE_URL Public service origin for discovery manifest
|
|
41
|
+
PUBLIC_REVOCATION_URL Public revocation endpoint for discovery manifest
|
|
42
|
+
PUBLIC_RECEIPTS_URL Public receipts endpoint for discovery manifest`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeString(value) {
|
|
46
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parsePackageSpecifier(specifier) {
|
|
50
|
+
if (specifier.startsWith('@')) {
|
|
51
|
+
const [scope, name, ...rest] = specifier.split('/');
|
|
52
|
+
return {
|
|
53
|
+
pkgName: `${scope}/${name}`,
|
|
54
|
+
subpath: rest.length ? `./${rest.join('/')}` : '.',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const [name, ...rest] = specifier.split('/');
|
|
58
|
+
return {
|
|
59
|
+
pkgName: name,
|
|
60
|
+
subpath: rest.length ? `./${rest.join('/')}` : '.',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function resolveExportTarget(pkgRoot, subpath) {
|
|
65
|
+
const packageJsonPath = path.join(pkgRoot, 'package.json');
|
|
66
|
+
const raw = await fs.readFile(packageJsonPath, 'utf8');
|
|
67
|
+
const pkg = JSON.parse(raw);
|
|
68
|
+
const exportsField = pkg.exports;
|
|
69
|
+
|
|
70
|
+
if (!exportsField) {
|
|
71
|
+
return pkg.main ? path.join(pkgRoot, pkg.main) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let target = null;
|
|
75
|
+
if (typeof exportsField === 'string') {
|
|
76
|
+
target = subpath === '.' ? exportsField : null;
|
|
77
|
+
} else if (exportsField[subpath]) {
|
|
78
|
+
target = exportsField[subpath];
|
|
79
|
+
} else if (subpath === '.' && exportsField['.']) {
|
|
80
|
+
target = exportsField['.'];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!target) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (typeof target === 'string') {
|
|
87
|
+
return path.join(pkgRoot, target);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const entry = target.import ?? target.default ?? target.require ?? null;
|
|
91
|
+
return entry ? path.join(pkgRoot, entry) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function resolveFromWebNodeModules(webRoot, specifier) {
|
|
95
|
+
const { pkgName, subpath } = parsePackageSpecifier(specifier);
|
|
96
|
+
const pkgRoot = path.join(webRoot, 'node_modules', pkgName);
|
|
97
|
+
return resolveExportTarget(pkgRoot, subpath);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildModuleLoader(webRoot) {
|
|
101
|
+
const packageJsonUrl = pathToFileURL(path.join(webRoot, 'package.json')).href;
|
|
102
|
+
|
|
103
|
+
return async (specifier) => {
|
|
104
|
+
let resolved = null;
|
|
105
|
+
if (typeof import.meta.resolve === 'function') {
|
|
106
|
+
try {
|
|
107
|
+
const resolvedUrl = import.meta.resolve(specifier, packageJsonUrl);
|
|
108
|
+
resolved = resolvedUrl.startsWith('file://')
|
|
109
|
+
? new URL(resolvedUrl)
|
|
110
|
+
: pathToFileURL(resolvedUrl);
|
|
111
|
+
} catch {
|
|
112
|
+
resolved = null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!resolved) {
|
|
116
|
+
const pathResolved = await resolveFromWebNodeModules(webRoot, specifier);
|
|
117
|
+
if (!pathResolved) {
|
|
118
|
+
throw new Error(`Unable to resolve ${specifier} from ${webRoot}/node_modules`);
|
|
119
|
+
}
|
|
120
|
+
resolved = pathToFileURL(pathResolved);
|
|
121
|
+
}
|
|
122
|
+
return import(resolved.href);
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function withMutedConsoleLog(fn) {
|
|
127
|
+
const originalLog = console.log;
|
|
128
|
+
console.log = () => {};
|
|
129
|
+
try {
|
|
130
|
+
return await fn();
|
|
131
|
+
} finally {
|
|
132
|
+
console.log = originalLog;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function capabilityCovers(provided, required) {
|
|
137
|
+
if (provided === '*' || provided === required) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (provided.endsWith('/*')) {
|
|
141
|
+
const prefix = provided.slice(0, -1);
|
|
142
|
+
return required.startsWith(prefix);
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isTruthy(value) {
|
|
148
|
+
return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function base64ToBytes(value) {
|
|
152
|
+
return new Uint8Array(Buffer.from(value, 'base64'));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function bytesToBase64(bytes) {
|
|
156
|
+
return Buffer.from(bytes).toString('base64');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function bytesToBase64Url(bytes) {
|
|
160
|
+
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function didStringFromPrincipal(principal) {
|
|
164
|
+
if (!principal) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (typeof principal.did === 'function') {
|
|
168
|
+
return principal.did();
|
|
169
|
+
}
|
|
170
|
+
return normalizeString(principal.did || principal);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function readJsonBody(req, bodyBuffer) {
|
|
174
|
+
if (!bodyBuffer || bodyBuffer.length === 0) {
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const payload = JSON.parse(bodyBuffer.toString('utf8'));
|
|
179
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
180
|
+
throw new Error('JSON body must be an object.');
|
|
181
|
+
}
|
|
182
|
+
return payload;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Invalid JSON body: ${error instanceof Error ? error.message : String(error)}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function parseDelegationProof(proof, importFromWeb) {
|
|
191
|
+
const base64UrlToBase64 = (value) => {
|
|
192
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
193
|
+
const remainder = normalized.length % 4;
|
|
194
|
+
return remainder === 0 ? normalized : normalized + '='.repeat(4 - remainder);
|
|
195
|
+
};
|
|
196
|
+
const bytes = proof.startsWith('m')
|
|
197
|
+
? new Uint8Array(Buffer.from(proof.slice(1), 'base64'))
|
|
198
|
+
: proof.startsWith('u')
|
|
199
|
+
? new Uint8Array(Buffer.from(base64UrlToBase64(proof.slice(1)), 'base64'))
|
|
200
|
+
: null;
|
|
201
|
+
if (!bytes) {
|
|
202
|
+
throw new Error('Unsupported bootstrap proof format');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const { extract } = await importFromWeb('@ucanto/core/delegation');
|
|
207
|
+
const extracted = await withMutedConsoleLog(() => extract(bytes));
|
|
208
|
+
if (extracted?.ok) {
|
|
209
|
+
return extracted.ok;
|
|
210
|
+
}
|
|
211
|
+
if (extracted && !extracted.error) {
|
|
212
|
+
return extracted;
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// Fall back to Storacha proof parsing.
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const Proof = await importFromWeb('@storacha/client/proof');
|
|
219
|
+
return withMutedConsoleLog(() => Proof.parse(proof));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function collectProofCids(proofs, seen = new Set()) {
|
|
223
|
+
for (const proof of proofs ?? []) {
|
|
224
|
+
const cid =
|
|
225
|
+
proof?.cid?.toString?.() ??
|
|
226
|
+
(typeof proof?.toString === 'function' ? proof.toString() : null);
|
|
227
|
+
if (cid && !seen.has(cid)) {
|
|
228
|
+
seen.add(cid);
|
|
229
|
+
collectProofCids(proof?.proofs ?? [], seen);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return seen;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function loadServiceSigner(config) {
|
|
236
|
+
const signerFile = normalizeString(config.serviceSignerFile);
|
|
237
|
+
if (!signerFile) {
|
|
238
|
+
throw new Error('UCAN_STORE_SERVICE_SIGNER_FILE is not configured.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { parse } = await config.policy.importFromWeb('@ucanto/principal/ed25519');
|
|
242
|
+
const raw = await fs.readFile(signerFile, 'utf8');
|
|
243
|
+
const signer = parse(raw.trim());
|
|
244
|
+
const configuredServiceDid = normalizeString(config.serviceDid);
|
|
245
|
+
return configuredServiceDid ? signer.withDID(configuredServiceDid) : signer;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolveRequestedCapabilities(payload, config) {
|
|
249
|
+
const requested = Array.isArray(payload.capabilities)
|
|
250
|
+
? payload.capabilities.map(normalizeString).filter(Boolean)
|
|
251
|
+
: [];
|
|
252
|
+
const requestedCapabilities =
|
|
253
|
+
requested.length > 0 ? requested : [...(config.policy.allowedCapabilities ?? [])];
|
|
254
|
+
|
|
255
|
+
if (requestedCapabilities.length === 0) {
|
|
256
|
+
throw new Error('No capabilities were requested and no bootstrap default exists.');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const capability of requestedCapabilities) {
|
|
260
|
+
const allowed = config.policy.allowedCapabilities.some((entry) =>
|
|
261
|
+
capabilityCovers(entry, capability),
|
|
262
|
+
);
|
|
263
|
+
if (!allowed) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Requested capability ${capability} is outside the configured bootstrap capability envelope.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return requestedCapabilities;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function resolveRequestedExpiration(payload, config) {
|
|
274
|
+
const defaultSeconds = Number.isInteger(config.policy.package.defaultUserDelegationExpiration)
|
|
275
|
+
? config.policy.package.defaultUserDelegationExpiration
|
|
276
|
+
: null;
|
|
277
|
+
const maxSeconds = Number.isInteger(config.policy.package.maxUserDelegationExpiration)
|
|
278
|
+
? config.policy.package.maxUserDelegationExpiration
|
|
279
|
+
: null;
|
|
280
|
+
|
|
281
|
+
let requestedSeconds = null;
|
|
282
|
+
if (payload.expirationSeconds === null) {
|
|
283
|
+
requestedSeconds = null;
|
|
284
|
+
} else if (Number.isInteger(payload.expirationSeconds) && payload.expirationSeconds >= 0) {
|
|
285
|
+
requestedSeconds = payload.expirationSeconds;
|
|
286
|
+
} else if (
|
|
287
|
+
typeof payload.expirationHours === 'number' &&
|
|
288
|
+
Number.isFinite(payload.expirationHours) &&
|
|
289
|
+
payload.expirationHours >= 0
|
|
290
|
+
) {
|
|
291
|
+
requestedSeconds = Math.floor(payload.expirationHours * 60 * 60);
|
|
292
|
+
} else if (payload.expirationSeconds !== undefined || payload.expirationHours !== undefined) {
|
|
293
|
+
throw new Error('expirationSeconds must be null or a non-negative integer.');
|
|
294
|
+
} else {
|
|
295
|
+
requestedSeconds = defaultSeconds;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (requestedSeconds === null && maxSeconds !== null) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
'An unbounded delegation expiration is not allowed when maxUserDelegationExpiration is configured.',
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (requestedSeconds !== null && maxSeconds !== null && requestedSeconds > maxSeconds) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Requested expiration ${requestedSeconds}s exceeds configured maxUserDelegationExpiration ${maxSeconds}s.`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return requestedSeconds;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function createDelegationExport(config, bodyBuffer, req) {
|
|
314
|
+
if (!config.policy) {
|
|
315
|
+
throw new Error('Bootstrap policy is unavailable; delegation issuance is disabled.');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const authHeader = normalizeString(req.headers.authorization);
|
|
319
|
+
const bearerPrefix = 'Bearer ';
|
|
320
|
+
const expectedToken = normalizeString(config.adminApiToken);
|
|
321
|
+
if (!expectedToken) {
|
|
322
|
+
return {
|
|
323
|
+
status: 503,
|
|
324
|
+
payload: {
|
|
325
|
+
status: 'disabled',
|
|
326
|
+
error:
|
|
327
|
+
'UCAN_STORE_ADMIN_API_TOKEN is not configured; admin delegation issuance is disabled.',
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (!authHeader || !authHeader.startsWith(bearerPrefix) || authHeader.slice(bearerPrefix.length) !== expectedToken) {
|
|
332
|
+
return {
|
|
333
|
+
status: 401,
|
|
334
|
+
payload: {
|
|
335
|
+
status: 'unauthorized',
|
|
336
|
+
error: 'Missing or invalid admin bearer token.',
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const payload = await readJsonBody(req, bodyBuffer);
|
|
342
|
+
const targetDid =
|
|
343
|
+
normalizeString(payload.targetDid) || normalizeString(payload.audienceDid) || null;
|
|
344
|
+
if (!targetDid || !targetDid.startsWith('did:')) {
|
|
345
|
+
throw new Error('targetDid must be a non-empty DID string.');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const capabilities = resolveRequestedCapabilities(payload, config);
|
|
349
|
+
const expirationSeconds = resolveRequestedExpiration(payload, config);
|
|
350
|
+
const { Verifier } = await config.policy.importFromWeb('@ucanto/principal');
|
|
351
|
+
const { delegate } = await config.policy.importFromWeb('@ucanto/core/delegation');
|
|
352
|
+
|
|
353
|
+
const serviceSigner = await loadServiceSigner(config);
|
|
354
|
+
const audience = Verifier.parse(targetDid);
|
|
355
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
356
|
+
const expiration =
|
|
357
|
+
expirationSeconds === null ? undefined : nowSeconds + expirationSeconds;
|
|
358
|
+
|
|
359
|
+
const delegation = await delegate({
|
|
360
|
+
issuer: serviceSigner,
|
|
361
|
+
audience,
|
|
362
|
+
capabilities: capabilities.map((can) => ({
|
|
363
|
+
with: config.policy.spaceDid,
|
|
364
|
+
can,
|
|
365
|
+
})),
|
|
366
|
+
expiration,
|
|
367
|
+
proofs: [config.policy.delegation],
|
|
368
|
+
facts: [],
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const archiveResult = await delegation.archive();
|
|
372
|
+
const carBytes =
|
|
373
|
+
archiveResult instanceof Uint8Array
|
|
374
|
+
? archiveResult
|
|
375
|
+
: archiveResult && typeof archiveResult === 'object' && archiveResult.ok instanceof Uint8Array
|
|
376
|
+
? archiveResult.ok
|
|
377
|
+
: null;
|
|
378
|
+
if (!carBytes || carBytes.length === 0) {
|
|
379
|
+
throw new Error('Failed to archive delegated proof.');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const proofBase64 = bytesToBase64(carBytes);
|
|
383
|
+
const proofBase64Url = bytesToBase64Url(carBytes);
|
|
384
|
+
const proof = `m${proofBase64}`;
|
|
385
|
+
const expiresAt = typeof expiration === 'number' ? new Date(expiration * 1000).toISOString() : null;
|
|
386
|
+
const proofCids = Array.from(collectProofCids([delegation]));
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
status: 200,
|
|
390
|
+
payload: {
|
|
391
|
+
status: 'ok',
|
|
392
|
+
delegation: {
|
|
393
|
+
cid: delegation.cid?.toString?.() ?? null,
|
|
394
|
+
issuerDid: didStringFromPrincipal(serviceSigner),
|
|
395
|
+
audienceDid: targetDid,
|
|
396
|
+
spaceDid: config.policy.spaceDid,
|
|
397
|
+
capabilities,
|
|
398
|
+
proof,
|
|
399
|
+
proofFormat: 'ucan-car-multibase-base64',
|
|
400
|
+
proofBase64,
|
|
401
|
+
proofBase64Url,
|
|
402
|
+
proofChainCids: proofCids,
|
|
403
|
+
expiresAt,
|
|
404
|
+
expiresInSeconds: expirationSeconds,
|
|
405
|
+
importHint: 'Paste the `proof` value into the ucan-store UI import form.',
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function buildServiceManifest(config) {
|
|
412
|
+
if (!config.policy) {
|
|
413
|
+
return {
|
|
414
|
+
status: 'unconfigured',
|
|
415
|
+
error: 'Bootstrap policy is unavailable.',
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const serviceOrigin =
|
|
420
|
+
normalizeString(config.publicUploadServiceUrl) ||
|
|
421
|
+
normalizeString(config.policy.package.serviceOrigin) ||
|
|
422
|
+
null;
|
|
423
|
+
const revocationUrl =
|
|
424
|
+
normalizeString(config.publicRevocationUrl) ||
|
|
425
|
+
serviceOrigin ||
|
|
426
|
+
null;
|
|
427
|
+
const receiptsUrl =
|
|
428
|
+
normalizeString(config.publicReceiptsUrl) ||
|
|
429
|
+
(serviceOrigin ? `${serviceOrigin.replace(/\/$/, '')}/receipt/` : null);
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
kind: 'ucan-store-service-manifest',
|
|
433
|
+
version: 1,
|
|
434
|
+
serviceDid: normalizeString(config.serviceDid) || null,
|
|
435
|
+
serviceOrigin,
|
|
436
|
+
didDocument: serviceOrigin ? `${serviceOrigin.replace(/\/$/, '')}/.well-known/did.json` : null,
|
|
437
|
+
pwaOrigin: normalizeString(config.policy.package.pwaOrigin) || null,
|
|
438
|
+
spaceDid: config.policy.spaceDid,
|
|
439
|
+
allowedCapabilities: [...(config.policy.allowedCapabilities ?? [])],
|
|
440
|
+
revocationUrl,
|
|
441
|
+
receiptsUrl,
|
|
442
|
+
bootstrapPolicy: {
|
|
443
|
+
defaultUserDelegationExpiration:
|
|
444
|
+
config.policy.package.defaultUserDelegationExpiration ?? null,
|
|
445
|
+
maxUserDelegationExpiration:
|
|
446
|
+
config.policy.package.maxUserDelegationExpiration ?? null,
|
|
447
|
+
},
|
|
448
|
+
delegationIssuance: {
|
|
449
|
+
enabled: Boolean(normalizeString(config.adminApiToken)),
|
|
450
|
+
endpoint: '/admin/delegations',
|
|
451
|
+
policyEndpoint: '/admin/delegations/policy',
|
|
452
|
+
proofFormat: 'ucan-car-multibase-base64',
|
|
453
|
+
},
|
|
454
|
+
discovery: {
|
|
455
|
+
manifestPath: '/.well-known/ucan-store.json',
|
|
456
|
+
aliases: ['/service-manifest.json'],
|
|
457
|
+
binding: 'domain-first',
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function loadBootstrapPolicy(args) {
|
|
463
|
+
const raw = await fs.readFile(args.packageFile, 'utf8');
|
|
464
|
+
const payload = JSON.parse(raw);
|
|
465
|
+
const importFromWeb = buildModuleLoader(args.webRoot);
|
|
466
|
+
const delegation = await parseDelegationProof(payload.rootDelegationProof, importFromWeb);
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
importFromWeb,
|
|
470
|
+
rootDelegationCid: delegation?.cid?.toString?.() ?? null,
|
|
471
|
+
delegation,
|
|
472
|
+
package: payload,
|
|
473
|
+
spaceDid: normalizeString(payload.spaceDid),
|
|
474
|
+
allowedCapabilities: Array.isArray(payload.allowedCapabilities)
|
|
475
|
+
? payload.allowedCapabilities.map(normalizeString).filter(Boolean)
|
|
476
|
+
: [],
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function proxyRequest(req, res, config, bodyBuffer = null) {
|
|
481
|
+
const url = new URL(req.url || '/', `http://${config.upstreamHost}:${config.upstreamPort}`);
|
|
482
|
+
const headers = new Headers();
|
|
483
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
484
|
+
if (value == null) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (['host', 'content-length', 'connection'].includes(key.toLowerCase())) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (Array.isArray(value)) {
|
|
491
|
+
for (const entry of value) {
|
|
492
|
+
headers.append(key, entry);
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
headers.set(key, value);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const response = await fetch(url, {
|
|
500
|
+
method: req.method,
|
|
501
|
+
headers,
|
|
502
|
+
body: bodyBuffer,
|
|
503
|
+
duplex: bodyBuffer ? 'half' : undefined,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
res.statusCode = response.status;
|
|
507
|
+
for (const [key, value] of response.headers.entries()) {
|
|
508
|
+
if (key.toLowerCase() === 'content-length') {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
res.setHeader(key, value);
|
|
512
|
+
}
|
|
513
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
514
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
515
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
516
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
517
|
+
res.end(Buffer.from(arrayBuffer));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function sendJson(res, status, payload) {
|
|
521
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
522
|
+
res.statusCode = status;
|
|
523
|
+
res.setHeader('Content-Type', 'application/json');
|
|
524
|
+
res.setHeader('Content-Length', String(body.length));
|
|
525
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
526
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
527
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
528
|
+
res.end(body);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function enforceRequestPolicy(bodyBuffer, req, config) {
|
|
532
|
+
if (!config.policy) {
|
|
533
|
+
return { ok: true };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const contentType = String(req.headers['content-type'] || '');
|
|
537
|
+
const isCarRequest =
|
|
538
|
+
contentType.includes('application/car') ||
|
|
539
|
+
contentType.includes('application/vnd.ipld.car');
|
|
540
|
+
if (!isCarRequest || req.method !== 'POST') {
|
|
541
|
+
return { ok: true };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
const CARTransport = await config.policy.importFromWeb('@ucanto/transport/car');
|
|
546
|
+
const message = await CARTransport.request.decode({
|
|
547
|
+
headers: req.headers,
|
|
548
|
+
body: bodyBuffer,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
for (const invocation of message.invocations ?? []) {
|
|
552
|
+
const proofCids = collectProofCids(invocation.proofs ?? []);
|
|
553
|
+
if (config.policy.rootDelegationCid && !proofCids.has(config.policy.rootDelegationCid)) {
|
|
554
|
+
return {
|
|
555
|
+
ok: false,
|
|
556
|
+
status: 403,
|
|
557
|
+
error: 'Invocation proof chain does not include the configured bootstrap root delegation.',
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
for (const capability of invocation.capabilities ?? []) {
|
|
562
|
+
const withValue = normalizeString(capability?.with);
|
|
563
|
+
const canValue = normalizeString(capability?.can);
|
|
564
|
+
if (!withValue || !canValue) {
|
|
565
|
+
return {
|
|
566
|
+
ok: false,
|
|
567
|
+
status: 403,
|
|
568
|
+
error: 'Invocation contains an incomplete capability.',
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
if (config.policy.spaceDid && withValue !== config.policy.spaceDid) {
|
|
572
|
+
return {
|
|
573
|
+
ok: false,
|
|
574
|
+
status: 403,
|
|
575
|
+
error: `Invocation capability ${canValue} targets ${withValue}, outside configured space ${config.policy.spaceDid}.`,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const allowed = config.policy.allowedCapabilities.some((entry) =>
|
|
579
|
+
capabilityCovers(entry, canValue),
|
|
580
|
+
);
|
|
581
|
+
if (!allowed) {
|
|
582
|
+
return {
|
|
583
|
+
ok: false,
|
|
584
|
+
status: 403,
|
|
585
|
+
error: `Invocation capability ${canValue} is outside the configured bootstrap capability envelope.`,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} catch (error) {
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
status: 400,
|
|
594
|
+
error: `Failed to decode UCAN request: ${error instanceof Error ? error.message : String(error)}`,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return { ok: true };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function main() {
|
|
602
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
603
|
+
printHelp();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const config = {
|
|
608
|
+
bindHost: DEFAULT_BIND_HOST,
|
|
609
|
+
bindPort: DEFAULT_BIND_PORT,
|
|
610
|
+
upstreamHost: '127.0.0.1',
|
|
611
|
+
upstreamPort: DEFAULT_UPSTREAM_PORT,
|
|
612
|
+
adminApiToken: DEFAULT_ADMIN_API_TOKEN,
|
|
613
|
+
serviceSignerFile: DEFAULT_SERVICE_SIGNER_FILE,
|
|
614
|
+
serviceDid: DEFAULT_SERVICE_DID,
|
|
615
|
+
publicUploadServiceUrl: DEFAULT_UPLOAD_SERVICE_URL,
|
|
616
|
+
publicRevocationUrl: DEFAULT_REVOCATION_URL,
|
|
617
|
+
publicReceiptsUrl: DEFAULT_RECEIPTS_URL,
|
|
618
|
+
policy: null,
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const requireBootstrap = isTruthy(DEFAULT_REQUIRE_BOOTSTRAP);
|
|
622
|
+
try {
|
|
623
|
+
config.policy = await loadBootstrapPolicy({
|
|
624
|
+
packageFile: DEFAULT_BOOTSTRAP_PACKAGE_FILE,
|
|
625
|
+
webRoot: DEFAULT_WEB_ROOT,
|
|
626
|
+
});
|
|
627
|
+
} catch (error) {
|
|
628
|
+
if (requireBootstrap) {
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
631
|
+
console.warn(
|
|
632
|
+
`Bootstrap package not loaded; request guard is running in passthrough mode: ${
|
|
633
|
+
error instanceof Error ? error.message : String(error)
|
|
634
|
+
}`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const server = http.createServer(async (req, res) => {
|
|
639
|
+
if (req.method === 'OPTIONS') {
|
|
640
|
+
res.statusCode = 204;
|
|
641
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
642
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
643
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
644
|
+
res.end();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const chunks = [];
|
|
649
|
+
for await (const chunk of req) {
|
|
650
|
+
chunks.push(chunk);
|
|
651
|
+
}
|
|
652
|
+
const bodyBuffer = chunks.length > 0 ? Buffer.concat(chunks) : null;
|
|
653
|
+
|
|
654
|
+
if (req.method === 'POST' && req.url === '/admin/delegations') {
|
|
655
|
+
try {
|
|
656
|
+
const result = await createDelegationExport(config, bodyBuffer, req);
|
|
657
|
+
sendJson(res, result.status, result.payload);
|
|
658
|
+
} catch (error) {
|
|
659
|
+
sendJson(res, 400, {
|
|
660
|
+
status: 'invalid_request',
|
|
661
|
+
error: error instanceof Error ? error.message : String(error),
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (req.method === 'GET' && req.url === '/admin/delegations/policy') {
|
|
668
|
+
const expectedToken = normalizeString(config.adminApiToken);
|
|
669
|
+
const authHeader = normalizeString(req.headers.authorization);
|
|
670
|
+
const authorized =
|
|
671
|
+
expectedToken &&
|
|
672
|
+
authHeader &&
|
|
673
|
+
authHeader.startsWith('Bearer ') &&
|
|
674
|
+
authHeader.slice('Bearer '.length) === expectedToken;
|
|
675
|
+
if (!authorized) {
|
|
676
|
+
sendJson(res, 401, {
|
|
677
|
+
status: 'unauthorized',
|
|
678
|
+
error: 'Missing or invalid admin bearer token.',
|
|
679
|
+
});
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
sendJson(res, 200, {
|
|
684
|
+
status: 'ok',
|
|
685
|
+
policy: {
|
|
686
|
+
serviceDid: normalizeString(config.serviceDid),
|
|
687
|
+
spaceDid: config.policy?.spaceDid ?? null,
|
|
688
|
+
allowedCapabilities: config.policy?.allowedCapabilities ?? [],
|
|
689
|
+
defaultUserDelegationExpiration:
|
|
690
|
+
config.policy?.package?.defaultUserDelegationExpiration ?? null,
|
|
691
|
+
maxUserDelegationExpiration:
|
|
692
|
+
config.policy?.package?.maxUserDelegationExpiration ?? null,
|
|
693
|
+
endpoint: '/admin/delegations',
|
|
694
|
+
proofFormat: 'ucan-car-multibase-base64',
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (
|
|
701
|
+
req.method === 'GET' &&
|
|
702
|
+
(req.url === '/.well-known/ucan-store.json' || req.url === '/service-manifest.json')
|
|
703
|
+
) {
|
|
704
|
+
sendJson(res, 200, {
|
|
705
|
+
status: 'ok',
|
|
706
|
+
manifest: buildServiceManifest(config),
|
|
707
|
+
});
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const policyResult = await enforceRequestPolicy(bodyBuffer, req, config);
|
|
712
|
+
if (!policyResult.ok) {
|
|
713
|
+
sendJson(res, policyResult.status, {
|
|
714
|
+
status: 'forbidden',
|
|
715
|
+
error: policyResult.error,
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
await proxyRequest(req, res, config, bodyBuffer);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
await new Promise((resolve, reject) => {
|
|
724
|
+
server.once('error', reject);
|
|
725
|
+
server.listen(config.bindPort, config.bindHost, resolve);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
const shutdown = () => {
|
|
729
|
+
server.close(() => {
|
|
730
|
+
process.exit(0);
|
|
731
|
+
});
|
|
732
|
+
};
|
|
733
|
+
process.on('SIGINT', shutdown);
|
|
734
|
+
process.on('SIGTERM', shutdown);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
main().catch((error) => {
|
|
738
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
739
|
+
process.exit(1);
|
|
740
|
+
});
|