@le-space/rootfs 0.6.0 → 0.6.2

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.
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import shlex
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ def shell_assign(name: str, value: str) -> str:
13
+ return f"{name}={shlex.quote(value)}"
14
+
15
+
16
+ def main() -> int:
17
+ parser = argparse.ArgumentParser(description="Read a ucan-store rootfs contract and emit shell exports.")
18
+ parser.add_argument("contract", type=Path, help="Path to the rootfs contract JSON file")
19
+ args = parser.parse_args()
20
+
21
+ try:
22
+ payload = json.loads(args.contract.read_text())
23
+ except FileNotFoundError:
24
+ print(f"Missing rootfs contract: {args.contract}", file=sys.stderr)
25
+ return 1
26
+ except json.JSONDecodeError as exc:
27
+ print(f"Invalid rootfs contract JSON in {args.contract}: {exc}", file=sys.stderr)
28
+ return 1
29
+
30
+ rootfs = payload.get("rootfs") or {}
31
+ services = payload.get("services") or {}
32
+ source = payload.get("source") or {}
33
+ manifest = payload.get("manifest") or {}
34
+ ports = payload.get("ports") or []
35
+
36
+ profile = rootfs.get("profile")
37
+ install_mode = rootfs.get("installMode")
38
+
39
+ if not isinstance(profile, str) or not profile.strip():
40
+ print("Rootfs contract is missing rootfs.profile", file=sys.stderr)
41
+ return 1
42
+ if not isinstance(install_mode, str) or not install_mode.strip():
43
+ print("Rootfs contract is missing rootfs.installMode", file=sys.stderr)
44
+ return 1
45
+ if not isinstance(ports, list):
46
+ print("Rootfs contract field ports must be a list", file=sys.stderr)
47
+ return 1
48
+
49
+ lines = [
50
+ shell_assign("ROOTFS_CONTRACT_PATH", str(args.contract.resolve())),
51
+ shell_assign("ROOTFS_CONTRACT_ID", str(payload.get("id", ""))),
52
+ shell_assign("ROOTFS_CONTRACT_PROFILE", profile.strip()),
53
+ shell_assign("ROOTFS_CONTRACT_INSTALL_MODE", install_mode.strip()),
54
+ shell_assign("ROOTFS_CONTRACT_SOURCE_SUBDIRECTORY", str(source.get("subdirectory", ""))),
55
+ shell_assign("ROOTFS_CONTRACT_INSTALL_DIR", str(rootfs.get("installDir", ""))),
56
+ shell_assign("ROOTFS_CONTRACT_BINARY_PATH", str(rootfs.get("binaryPath", "/usr/local/bin/ucan-store"))),
57
+ shell_assign("ROOTFS_CONTRACT_DATA_DIR", str(rootfs.get("dataDir", ""))),
58
+ shell_assign("ROOTFS_CONTRACT_ENV_FILE", str(rootfs.get("envFile", ""))),
59
+ shell_assign("ROOTFS_CONTRACT_MAIN_SERVICE", str(services.get("main", ""))),
60
+ shell_assign("ROOTFS_CONTRACT_BOOTSTRAP_SERVICE", str(services.get("bootstrap", ""))),
61
+ shell_assign("ROOTFS_CONTRACT_AUTOTLS_SERVICE", str(services.get("autotlsRefresh", ""))),
62
+ shell_assign("ROOTFS_CONTRACT_MANIFEST_COPY_TARGET", str(manifest.get("copyTarget", ""))),
63
+ shell_assign("ROOTFS_CONTRACT_MANIFEST_NOTES", str(manifest.get("notes", ""))),
64
+ shell_assign("ROOTFS_CONTRACT_PORT_FORWARDS_JSON", json.dumps(ports, separators=(",", ":"))),
65
+ ]
66
+
67
+ print("\n".join(lines))
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ raise SystemExit(main())
@@ -0,0 +1,12 @@
1
+ [Unit]
2
+ Description=Placeholder AutoTLS refresh hook for ucan-store
3
+ After=ucan-store.service
4
+ Wants=ucan-store.service
5
+ ConditionPathExists=/etc/default/ucan-store.ready
6
+
7
+ [Service]
8
+ Type=oneshot
9
+ ExecStart=/bin/true
10
+
11
+ [Install]
12
+ WantedBy=multi-user.target
@@ -0,0 +1,409 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+
6
+ const DEFAULT_WEB_ROOT = process.env.WEB_ROOT || '/opt/ucan-store/web';
7
+
8
+ function parseArgs(argv) {
9
+ const args = {
10
+ packageFile: '',
11
+ adminDid: '',
12
+ runtimeServiceDid: '',
13
+ runtimeServiceOrigin: '',
14
+ webRoot: DEFAULT_WEB_ROOT,
15
+ summaryFile: '',
16
+ };
17
+
18
+ for (let index = 0; index < argv.length; index += 1) {
19
+ const arg = argv[index];
20
+ const value = argv[index + 1] ?? '';
21
+ switch (arg) {
22
+ case '--package-file':
23
+ args.packageFile = value;
24
+ index += 1;
25
+ break;
26
+ case '--admin-did':
27
+ args.adminDid = value;
28
+ index += 1;
29
+ break;
30
+ case '--runtime-service-did':
31
+ args.runtimeServiceDid = value;
32
+ index += 1;
33
+ break;
34
+ case '--runtime-service-origin':
35
+ args.runtimeServiceOrigin = value;
36
+ index += 1;
37
+ break;
38
+ case '--web-root':
39
+ args.webRoot = value;
40
+ index += 1;
41
+ break;
42
+ case '--summary-file':
43
+ args.summaryFile = value;
44
+ index += 1;
45
+ break;
46
+ case '-h':
47
+ case '--help':
48
+ printHelp();
49
+ process.exit(0);
50
+ default:
51
+ throw new Error(`Unknown argument: ${arg}`);
52
+ }
53
+ }
54
+
55
+ if (!args.packageFile) {
56
+ throw new Error('--package-file is required');
57
+ }
58
+
59
+ return args;
60
+ }
61
+
62
+ function printHelp() {
63
+ console.log(`Usage:
64
+ ucan-store-bootstrap-verify.mjs \\
65
+ --package-file <path> \\
66
+ [--admin-did <did>] \\
67
+ [--runtime-service-did <did>] \\
68
+ [--runtime-service-origin <origin>] \\
69
+ [--web-root <path>] \\
70
+ [--summary-file <path>]`);
71
+ }
72
+
73
+ function normalizeString(value) {
74
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
75
+ }
76
+
77
+ function normalizeOrigin(value) {
78
+ const normalized = normalizeString(value);
79
+ if (!normalized) {
80
+ return null;
81
+ }
82
+ try {
83
+ const url = new URL(normalized);
84
+ if (!['http:', 'https:'].includes(url.protocol)) {
85
+ return null;
86
+ }
87
+ if ((url.pathname && url.pathname !== '/') || url.search || url.hash) {
88
+ return null;
89
+ }
90
+ return `${url.protocol}//${url.host}`;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function base64UrlToBase64(value) {
97
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/');
98
+ const remainder = padded.length % 4;
99
+ if (remainder === 0) {
100
+ return padded;
101
+ }
102
+ return padded + '='.repeat(4 - remainder);
103
+ }
104
+
105
+ function decodeProofString(proof) {
106
+ if (proof.startsWith('m')) {
107
+ return new Uint8Array(Buffer.from(proof.slice(1), 'base64'));
108
+ }
109
+ if (proof.startsWith('u')) {
110
+ return new Uint8Array(Buffer.from(base64UrlToBase64(proof.slice(1)), 'base64'));
111
+ }
112
+ throw new Error('Unsupported delegation proof multibase prefix');
113
+ }
114
+
115
+ function capabilityCovers(provided, required) {
116
+ if (provided === '*' || provided === required) {
117
+ return true;
118
+ }
119
+ if (provided.endsWith('/*')) {
120
+ const prefix = provided.slice(0, -1);
121
+ return required.startsWith(prefix);
122
+ }
123
+ return false;
124
+ }
125
+
126
+ function parsePackageSpecifier(specifier) {
127
+ if (specifier.startsWith('@')) {
128
+ const [scope, name, ...rest] = specifier.split('/');
129
+ return {
130
+ pkgName: `${scope}/${name}`,
131
+ subpath: rest.length ? `./${rest.join('/')}` : '.',
132
+ };
133
+ }
134
+ const [name, ...rest] = specifier.split('/');
135
+ return {
136
+ pkgName: name,
137
+ subpath: rest.length ? `./${rest.join('/')}` : '.',
138
+ };
139
+ }
140
+
141
+ async function resolveExportTarget(pkgRoot, subpath) {
142
+ const packageJsonPath = path.join(pkgRoot, 'package.json');
143
+ const raw = await fs.readFile(packageJsonPath, 'utf8');
144
+ const pkg = JSON.parse(raw);
145
+ const exportsField = pkg.exports;
146
+
147
+ if (!exportsField) {
148
+ return pkg.main ? path.join(pkgRoot, pkg.main) : null;
149
+ }
150
+
151
+ let target = null;
152
+ if (typeof exportsField === 'string') {
153
+ target = subpath === '.' ? exportsField : null;
154
+ } else if (exportsField[subpath]) {
155
+ target = exportsField[subpath];
156
+ } else if (subpath === '.' && exportsField['.']) {
157
+ target = exportsField['.'];
158
+ }
159
+
160
+ if (!target) {
161
+ return null;
162
+ }
163
+ if (typeof target === 'string') {
164
+ return path.join(pkgRoot, target);
165
+ }
166
+
167
+ const entry = target.import ?? target.default ?? target.require ?? null;
168
+ return entry ? path.join(pkgRoot, entry) : null;
169
+ }
170
+
171
+ async function resolveFromWebNodeModules(webRoot, specifier) {
172
+ const { pkgName, subpath } = parsePackageSpecifier(specifier);
173
+ const pkgRoot = path.join(webRoot, 'node_modules', pkgName);
174
+ return resolveExportTarget(pkgRoot, subpath);
175
+ }
176
+
177
+ function buildModuleLoader(webRoot) {
178
+ const packageJsonUrl = pathToFileURL(path.join(webRoot, 'package.json')).href;
179
+
180
+ return async (specifier) => {
181
+ let resolved = null;
182
+ if (typeof import.meta.resolve === 'function') {
183
+ try {
184
+ const resolvedUrl = import.meta.resolve(specifier, packageJsonUrl);
185
+ resolved = resolvedUrl.startsWith('file://')
186
+ ? fileURLToPath(resolvedUrl)
187
+ : resolvedUrl;
188
+ } catch {
189
+ resolved = null;
190
+ }
191
+ }
192
+ if (!resolved) {
193
+ resolved = await resolveFromWebNodeModules(webRoot, specifier);
194
+ }
195
+ return import(pathToFileURL(resolved).href);
196
+ };
197
+ }
198
+
199
+ async function parseDelegation(proof, importFromWeb) {
200
+ const bytes = decodeProofString(proof);
201
+ try {
202
+ const { extract } = await importFromWeb('@ucanto/core/delegation');
203
+ const extracted = await withMutedConsoleLog(() => extract(bytes));
204
+ if (extracted?.ok) {
205
+ return extracted.ok;
206
+ }
207
+ if (extracted && !extracted.error) {
208
+ return extracted;
209
+ }
210
+ } catch {
211
+ // Fall back to Storacha proof parser.
212
+ }
213
+
214
+ const Proof = await importFromWeb('@storacha/client/proof');
215
+ return withMutedConsoleLog(() => Proof.parse(proof));
216
+ }
217
+
218
+ async function verifyDelegationSignature(delegation, importFromWeb) {
219
+ const issuerDid = delegation?.issuer?.did?.();
220
+ if (!issuerDid) {
221
+ return { ok: false, error: 'Delegation issuer DID is missing.' };
222
+ }
223
+ if (!issuerDid.startsWith('did:key:')) {
224
+ return {
225
+ ok: false,
226
+ error: `Delegation issuer ${issuerDid} is not a did:key DID; bootstrap verification currently supports did:key issuers only.`,
227
+ };
228
+ }
229
+
230
+ const { Verifier } = await importFromWeb('@ucanto/principal');
231
+ const UCAN = await importFromWeb('@ipld/dag-ucan');
232
+ const verifier = Verifier.parse(issuerDid);
233
+ const valid = await withMutedConsoleLog(() =>
234
+ UCAN.verifySignature(delegation.data, verifier),
235
+ );
236
+ return valid
237
+ ? { ok: true }
238
+ : { ok: false, error: `Delegation signature verification failed for issuer ${issuerDid}.` };
239
+ }
240
+
241
+ async function withMutedConsoleLog(fn) {
242
+ const originalLog = console.log;
243
+ console.log = () => {};
244
+ try {
245
+ return await fn();
246
+ } finally {
247
+ console.log = originalLog;
248
+ }
249
+ }
250
+
251
+ async function main() {
252
+ const args = parseArgs(process.argv.slice(2));
253
+ const importFromWeb = buildModuleLoader(args.webRoot);
254
+ const errors = [];
255
+ const warnings = [];
256
+
257
+ const raw = await fs.readFile(args.packageFile, 'utf8');
258
+ const payload = JSON.parse(raw);
259
+ const adminDid = normalizeString(args.adminDid) ?? normalizeString(payload.adminDid);
260
+ const packageServiceDid = normalizeString(payload.serviceDid);
261
+ const runtimeServiceDid = normalizeString(args.runtimeServiceDid);
262
+ const runtimeServiceOrigin = normalizeOrigin(args.runtimeServiceOrigin);
263
+ const allowedCapabilities = Array.isArray(payload.allowedCapabilities)
264
+ ? payload.allowedCapabilities.map(normalizeString).filter(Boolean)
265
+ : [];
266
+ const spaceDid = normalizeString(payload.spaceDid);
267
+ const proof = normalizeString(payload.rootDelegationProof);
268
+
269
+ if (!proof) {
270
+ throw new Error('Bootstrap package rootDelegationProof is missing.');
271
+ }
272
+
273
+ const delegation = await parseDelegation(proof, importFromWeb);
274
+ const issuerDid = delegation?.issuer?.did?.() ?? null;
275
+ const audienceDid = delegation?.audience?.did?.() ?? null;
276
+ const capabilities = Array.isArray(delegation?.capabilities)
277
+ ? delegation.capabilities.map((capability) => ({
278
+ with: normalizeString(capability?.with) ?? null,
279
+ can: normalizeString(capability?.can) ?? null,
280
+ }))
281
+ : [];
282
+
283
+ if (!issuerDid) {
284
+ errors.push('Delegation issuer DID could not be determined.');
285
+ }
286
+ if (!audienceDid) {
287
+ errors.push('Delegation audience DID could not be determined.');
288
+ }
289
+
290
+ if (adminDid && issuerDid && adminDid !== issuerDid) {
291
+ errors.push(`Delegation issuer ${issuerDid} does not match configured admin DID ${adminDid}.`);
292
+ }
293
+
294
+ const expectedAudience = runtimeServiceDid ?? packageServiceDid;
295
+ if (expectedAudience && audienceDid && expectedAudience !== audienceDid) {
296
+ errors.push(
297
+ `Delegation audience ${audienceDid} does not match expected service DID ${expectedAudience}.`,
298
+ );
299
+ }
300
+
301
+ if (runtimeServiceDid && packageServiceDid && runtimeServiceDid !== packageServiceDid) {
302
+ errors.push(
303
+ `Runtime service DID ${runtimeServiceDid} does not match bootstrap package serviceDid ${packageServiceDid}.`,
304
+ );
305
+ }
306
+
307
+ const signatureCheck = await verifyDelegationSignature(delegation, importFromWeb);
308
+ if (!signatureCheck.ok) {
309
+ errors.push(signatureCheck.error);
310
+ }
311
+
312
+ const nowSeconds = Math.floor(Date.now() / 1000);
313
+ const expiration =
314
+ typeof delegation?.expiration === 'number' && Number.isFinite(delegation.expiration)
315
+ ? delegation.expiration
316
+ : null;
317
+ const notBefore =
318
+ typeof delegation?.notBefore === 'number' && Number.isFinite(delegation.notBefore)
319
+ ? delegation.notBefore
320
+ : null;
321
+
322
+ if (expiration !== null && expiration <= nowSeconds) {
323
+ errors.push(`Delegation expired at ${new Date(expiration * 1000).toISOString()}.`);
324
+ }
325
+ if (notBefore !== null && notBefore > nowSeconds) {
326
+ errors.push(`Delegation is not valid before ${new Date(notBefore * 1000).toISOString()}.`);
327
+ }
328
+
329
+ if (spaceDid) {
330
+ const invalidScopes = capabilities.filter(
331
+ (capability) => capability.with && capability.with !== spaceDid,
332
+ );
333
+ if (invalidScopes.length > 0) {
334
+ warnings.push(
335
+ `Delegation contains capabilities outside the configured space DID ${spaceDid}.`,
336
+ );
337
+ }
338
+ }
339
+
340
+ const missingCapabilities = [];
341
+ for (const requiredCapability of allowedCapabilities) {
342
+ const covered = capabilities.some(
343
+ (capability) =>
344
+ capability.with === spaceDid &&
345
+ capability.can &&
346
+ capabilityCovers(capability.can, requiredCapability),
347
+ );
348
+ if (!covered) {
349
+ missingCapabilities.push(requiredCapability);
350
+ }
351
+ }
352
+ if (missingCapabilities.length > 0) {
353
+ errors.push(
354
+ `Delegation does not cover required capabilities: ${missingCapabilities.join(', ')}.`,
355
+ );
356
+ }
357
+
358
+ const packageServiceOrigin = normalizeOrigin(payload.serviceOrigin);
359
+ if (runtimeServiceOrigin && packageServiceOrigin && runtimeServiceOrigin !== packageServiceOrigin) {
360
+ errors.push(
361
+ `Runtime service origin ${runtimeServiceOrigin} does not match bootstrap package serviceOrigin ${packageServiceOrigin}.`,
362
+ );
363
+ }
364
+
365
+ const summary = {
366
+ status: errors.length === 0 ? 'valid' : 'invalid',
367
+ valid: errors.length === 0,
368
+ errors,
369
+ warnings,
370
+ verification: {
371
+ issuerDid,
372
+ audienceDid,
373
+ adminDid,
374
+ packageServiceDid,
375
+ runtimeServiceDid,
376
+ runtimeServiceOrigin,
377
+ packageServiceOrigin,
378
+ spaceDid,
379
+ signatureValid: signatureCheck.ok,
380
+ expiration,
381
+ notBefore,
382
+ capabilities,
383
+ allowedCapabilities,
384
+ missingCapabilities,
385
+ },
386
+ };
387
+
388
+ if (args.summaryFile) {
389
+ await fs.mkdir(path.dirname(args.summaryFile), { recursive: true });
390
+ await fs.writeFile(args.summaryFile, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
391
+ }
392
+
393
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
394
+ if (!summary.valid) {
395
+ process.exitCode = 1;
396
+ }
397
+ }
398
+
399
+ main().catch((error) => {
400
+ const summary = {
401
+ status: 'invalid',
402
+ valid: false,
403
+ errors: [error instanceof Error ? error.message : String(error)],
404
+ warnings: [],
405
+ verification: null,
406
+ };
407
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
408
+ process.exitCode = 1;
409
+ });
@@ -0,0 +1,21 @@
1
+ [Unit]
2
+ Description=Temporary ucan-store setup endpoint
3
+ Wants=network-online.target
4
+ After=network-online.target
5
+ ConditionPathExists=!/etc/default/ucan-store.ready
6
+
7
+ [Service]
8
+ Environment=ENV_FILE=/etc/default/ucan-store
9
+ Environment=READY_FILE=/etc/default/ucan-store.ready
10
+ Environment=SERVICE_NAME=ucan-store.service
11
+ Environment=BOOTSTRAP_SERVICE=ucan-store-bootstrap.service
12
+ Environment=METADATA_FILE=/run/ucan-store-setup-metadata.json
13
+ Environment=METADATA_ERROR_FILE=/run/ucan-store-setup-metadata.error
14
+ ExecStart=/usr/bin/python3 /usr/local/sbin/ucan-store-setup-server.py
15
+ StandardOutput=journal
16
+ StandardError=journal
17
+ Restart=on-failure
18
+ RestartSec=2s
19
+
20
+ [Install]
21
+ WantedBy=multi-user.target
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ INSTALL_DIR="${INSTALL_DIR:-/opt/ucan-store}"
5
+ SERVICE_USER="${SERVICE_USER:-ucan-store}"
6
+ DATA_DIR="${DATA_DIR:-/var/lib/ucan-store}"
7
+ ENV_FILE="${ENV_FILE:-/etc/default/ucan-store}"
8
+ READY_FILE="${READY_FILE:-/etc/default/ucan-store.ready}"
9
+ BOOTSTRAP_PACKAGE_FILE="${BOOTSTRAP_PACKAGE_FILE:-/etc/ucan-store/bootstrap-package.json}"
10
+ BOOTSTRAP_VERIFICATION_FILE="${BOOTSTRAP_VERIFICATION_FILE:-/etc/ucan-store/bootstrap-verification.json}"
11
+ APP_BINARY="${APP_BINARY:-/usr/local/bin/ucan-store}"
12
+ NODE_MIN_MAJOR="${NODE_MIN_MAJOR:-22}"
13
+ UPLOAD_API_CONTEXT_TEMPLATE="${UPLOAD_API_CONTEXT_TEMPLATE:-/usr/local/share/ucan-store/ucan-store-upload-api-context.js}"
14
+ UPLOAD_API_SERVICE_IDENTITY_TEMPLATE="${UPLOAD_API_SERVICE_IDENTITY_TEMPLATE:-/usr/local/share/ucan-store/ucan-store-upload-api-service-identity.js}"
15
+ PHASE="${1:-all}"
16
+
17
+ if [ ! -d "${INSTALL_DIR}" ]; then
18
+ echo "Missing ${INSTALL_DIR}; the rootfs build did not copy ucan-store." >&2
19
+ exit 1
20
+ fi
21
+
22
+ write_env_var() {
23
+ local key="$1"
24
+ local value="$2"
25
+
26
+ if grep -Eq "^[#[:space:]]*${key}=" "${ENV_FILE}"; then
27
+ sed -i "s|^[#[:space:]]*${key}=.*|${key}=${value}|" "${ENV_FILE}"
28
+ else
29
+ printf '%s=%s\n' "${key}" "${value}" >> "${ENV_FILE}"
30
+ fi
31
+ }
32
+
33
+ run_phase_base() {
34
+ export DEBIAN_FRONTEND=noninteractive
35
+ apt-get update
36
+ apt-get install -y ca-certificates curl gnupg caddy python3
37
+
38
+ if ! command -v node >/dev/null 2>&1 || [ "$(node -p "process.versions.node.split('.')[0]" 2>/dev/null || echo 0)" -lt "${NODE_MIN_MAJOR}" ]; then
39
+ curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
40
+ apt-get install -y nodejs
41
+ fi
42
+
43
+ rm -rf /var/lib/apt/lists/*
44
+ }
45
+
46
+ run_phase_build() {
47
+ if [ ! -f "${INSTALL_DIR}/web/package.json" ]; then
48
+ echo "Missing ${INSTALL_DIR}/web/package.json" >&2
49
+ exit 1
50
+ fi
51
+ if [ ! -f "${INSTALL_DIR}/web/package-lock.json" ]; then
52
+ echo "Missing ${INSTALL_DIR}/web/package-lock.json" >&2
53
+ exit 1
54
+ fi
55
+
56
+ mkdir -p "${DATA_DIR}" "$(dirname "${ENV_FILE}")"
57
+ if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
58
+ useradd --system --home "${DATA_DIR}" --create-home --shell /usr/sbin/nologin "${SERVICE_USER}"
59
+ fi
60
+
61
+ chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DATA_DIR}" "${INSTALL_DIR}"
62
+
63
+ if command -v runuser >/dev/null 2>&1; then
64
+ runuser -u "${SERVICE_USER}" -- env HOME="${DATA_DIR}" bash -lc "cd '${INSTALL_DIR}/web' && npm ci"
65
+ else
66
+ echo "runuser is required to install npm dependencies as ${SERVICE_USER}" >&2
67
+ exit 1
68
+ fi
69
+
70
+ install_upload_api_context_override
71
+
72
+ rm -rf "${DATA_DIR}/.npm" "${INSTALL_DIR}/web/node_modules/.cache"
73
+ }
74
+
75
+ install_upload_api_context_override() {
76
+ local helper_dir="${INSTALL_DIR}/web/node_modules/@storacha/upload-api/dist/test/helpers"
77
+ mkdir -p "${helper_dir}"
78
+
79
+ if [ ! -f "${UPLOAD_API_CONTEXT_TEMPLATE}" ]; then
80
+ echo "Missing upload-api context template: ${UPLOAD_API_CONTEXT_TEMPLATE}" >&2
81
+ exit 1
82
+ fi
83
+ if [ ! -f "${UPLOAD_API_SERVICE_IDENTITY_TEMPLATE}" ]; then
84
+ echo "Missing upload-api service identity template: ${UPLOAD_API_SERVICE_IDENTITY_TEMPLATE}" >&2
85
+ exit 1
86
+ fi
87
+
88
+ cp "${UPLOAD_API_CONTEXT_TEMPLATE}" "${helper_dir}/context.js"
89
+ cp "${UPLOAD_API_SERVICE_IDENTITY_TEMPLATE}" "${helper_dir}/ucan-store-service-identity.js"
90
+ chown "${SERVICE_USER}:${SERVICE_USER}" "${helper_dir}/context.js" "${helper_dir}/ucan-store-service-identity.js"
91
+ }
92
+
93
+ run_phase_finalize() {
94
+ mkdir -p "${DATA_DIR}" "$(dirname "${ENV_FILE}")" /etc/caddy "$(dirname "${BOOTSTRAP_PACKAGE_FILE}")"
95
+ chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DATA_DIR}" "${INSTALL_DIR}"
96
+
97
+ cat > "${APP_BINARY}" <<'EOF'
98
+ #!/usr/bin/env bash
99
+ set -euo pipefail
100
+ exec /usr/bin/node /opt/ucan-store/local-storacha-api/index.mjs "$@"
101
+ EOF
102
+ chmod 0755 "${APP_BINARY}"
103
+
104
+ touch "${ENV_FILE}"
105
+ chmod 0640 "${ENV_FILE}"
106
+ chown "root:${SERVICE_USER}" "${ENV_FILE}"
107
+ rm -f "${READY_FILE}"
108
+
109
+ write_env_var "NODE_ENV" "production"
110
+ write_env_var "STORACHA_LOCAL_PORT" "8787"
111
+ write_env_var "UCAN_STORE_PROXY_PORT" "8788"
112
+ write_env_var "UCAN_STORE_SERVICE_KEY_ALGORITHM" "ed25519"
113
+ write_env_var "UCAN_STORE_SERVICE_SIGNER_FILE" "${DATA_DIR}/service-ed25519.key"
114
+ write_env_var "UCAN_STORE_SERVICE_DID" ""
115
+ write_env_var "UCAN_STORE_ADMIN_API_TOKEN" ""
116
+ write_env_var "WEBAUTHN_ORIGIN" "http://localhost:5173"
117
+ write_env_var "WEBAUTHN_ORIGIN_FALLBACKS" ""
118
+ write_env_var "UCAN_STORE_ADMIN_DID" ""
119
+ write_env_var "UCAN_STORE_BOOTSTRAP_PACKAGE_FILE" "${BOOTSTRAP_PACKAGE_FILE}"
120
+ write_env_var "UCAN_STORE_BOOTSTRAP_VERIFICATION_FILE" "${BOOTSTRAP_VERIFICATION_FILE}"
121
+ write_env_var "UCAN_STORE_REQUIRE_BOOTSTRAP_PACKAGE" "1"
122
+ write_env_var "BOOTSTRAP_VERIFICATION_TIMEOUT_SECONDS" "60"
123
+ write_env_var "BOOTSTRAP_VERIFICATION_INTERVAL_SECONDS" "2"
124
+ write_env_var "PUBLIC_IPV4" ""
125
+ write_env_var "PUBLIC_IPV6" ""
126
+ write_env_var "PROXY_HOSTNAME" ""
127
+ write_env_var "PUBLIC_UPLOAD_SERVICE_URL" ""
128
+ write_env_var "PUBLIC_UPLOAD_SERVICE_DID" ""
129
+ write_env_var "PUBLIC_REVOCATION_URL" ""
130
+ write_env_var "PUBLIC_REVOCATION_DID" ""
131
+ write_env_var "PUBLIC_RECEIPTS_URL" ""
132
+
133
+ rm -f "${BOOTSTRAP_PACKAGE_FILE}" "${BOOTSTRAP_VERIFICATION_FILE}"
134
+ }
135
+
136
+ case "${PHASE}" in
137
+ base)
138
+ run_phase_base
139
+ ;;
140
+ build)
141
+ run_phase_build
142
+ ;;
143
+ finalize)
144
+ run_phase_finalize
145
+ ;;
146
+ all)
147
+ run_phase_base
148
+ run_phase_build
149
+ run_phase_finalize
150
+ ;;
151
+ *)
152
+ echo "Unknown phase: ${PHASE}" >&2
153
+ exit 1
154
+ ;;
155
+ esac