@runeditrun/rer 0.1.1 → 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 +111 -1
- package/cli/README.md +28 -3
- package/cli/contract.mjs +4 -4
- package/cli/dev-identity.mjs +2 -1
- package/cli/door-mode.mjs +134 -0
- package/cli/door.mjs +263 -0
- package/cli/hosted-output.mjs +15 -5
- package/cli/hydration/browser.mjs +91 -0
- package/cli/hydration/cases.mjs +106 -0
- package/cli/hydration/check.mjs +105 -0
- package/cli/hydration/probe.mjs +164 -0
- package/cli/hydration/scripts.mjs +39 -0
- package/cli/hydration/verdict.mjs +85 -0
- package/cli/inspect.mjs +56 -9
- package/cli/lifecycle.mjs +36 -12
- package/cli/local.mjs +9 -3
- package/cli/main.mjs +2 -0
- package/cli/managed-server.mjs +52 -18
- package/cli/package-calls.mjs +81 -0
- package/cli/package-mode.mjs +46 -0
- package/cli/parse.mjs +14 -4
- package/cli/pnpm-runtime.mjs +96 -0
- package/cli/portability-worker.mjs +2 -2
- package/cli/runtime-front-door.mjs +4 -2
- package/cli/server-proxy.mjs +69 -5
- package/cli/server-registry.mjs +10 -7
- package/cli/settings.mjs +14 -0
- package/cli/site-check.mjs +96 -0
- package/cli/site-preview.mjs +53 -0
- package/cli/site-render.mjs +77 -0
- package/cli/site.mjs +109 -0
- package/global/CHANGELOG.md +63 -0
- package/global/README.md +111 -1
- package/global/checkout.mjs +24 -10
- package/global/cli/install.mjs +6 -2
- package/global/delegate.mjs +9 -7
- package/global/door/command.mjs +254 -0
- package/global/door/control.mjs +65 -0
- package/global/door/cookies.mjs +41 -0
- package/global/door/pages.mjs +49 -0
- package/global/door/policy.mjs +49 -0
- package/global/door/routes.mjs +45 -0
- package/global/door/server.mjs +255 -0
- package/global/door/sites.mjs +192 -0
- package/global/launch/dev.mjs +21 -7
- package/global/launch/door.mjs +36 -0
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +38 -5
- package/global/launch/render.mjs +6 -1
- package/global/launch/steps.mjs +12 -7
- package/global/launch/worker.mjs +3 -3
- package/global/list.mjs +6 -3
- package/global/main.mjs +37 -3
- package/global/version.mjs +8 -8
- package/modules/dependencies/protocol.mjs +2 -1
- package/modules/deploy-kit/README.md +34 -1
- package/modules/deploy-kit/artifact.mjs +10 -0
- package/modules/deploy-kit/owner.mjs +5 -4
- package/modules/deploy-kit/prepare.mjs +3 -2
- package/modules/deploy-kit/provider.mjs +6 -3
- package/modules/deploy-kit/site.mjs +135 -0
- package/modules/erasure/CONTRACT.md +6 -3
- package/modules/erasure/README.md +8 -1
- package/modules/erasure/audit.mjs +2 -2
- package/modules/erasure/cascade.mjs +52 -11
- package/modules/erasure/order.mjs +29 -22
- package/modules/erasure/plan.mjs +4 -2
- package/modules/erasure/queue.mjs +41 -0
- package/modules/erasure/scope.mjs +3 -3
- package/modules/erasure/verification.mjs +4 -2
- package/modules/mail/README.md +1 -1
- package/modules/mail/index.mjs +1 -1
- package/modules/mail/src/core/errors.mjs +9 -0
- package/modules/mail/src/development/index.d.mts +6 -0
- package/modules/mail/src/development/index.mjs +6 -3
- package/modules/mail/src/development/viewer-html.mjs +3 -3
- package/modules/mail/src/development/viewer.mjs +8 -1
- package/modules/operator-auth/dev/index.mjs +15 -5
- package/modules/operator-auth/http.mjs +2 -3
- package/modules/operator-auth/loopback.mjs +17 -0
- package/modules/owner-claim/verify.mjs +2 -1
- package/modules/portability/README.md +8 -0
- package/modules/portability/hash.mjs +6 -2
- package/modules/portability/import.mjs +1 -1
- package/modules/portability/legacy-files.mjs +4 -4
- package/modules/portability/legacy-safety.mjs +51 -10
- package/modules/portability/migration-history.mjs +15 -0
- package/modules/release/README.md +5 -3
- package/modules/release/produce.mjs +5 -2
- package/modules/release/source.mjs +15 -4
- package/onboarding/adapter-auth.mjs +4 -3
- package/onboarding/adapter-settings.mjs +21 -5
- package/onboarding/adapter.mjs +2 -1
- package/onboarding/app-fetch.mjs +79 -0
- package/onboarding/archive-output.d.mts +4 -2
- package/onboarding/archive-output.mjs +22 -10
- package/onboarding/dependency-approval.mjs +4 -3
- package/onboarding/local/environment.mjs +7 -2
- package/onboarding/operator-install.mjs +8 -2
- package/onboarding/operator-prerequisites.mjs +5 -2
- package/onboarding/queued-entry.mjs +9 -5
- package/operations/operations.mjs +11 -2
- package/package.json +75 -1
- package/scripts/publish-check/github_refs.py +28 -2
- package/scripts/publish-check/public_hygiene.py +35 -4
- package/scripts/publish-check/publication-config.json +14 -1
|
@@ -50,7 +50,7 @@ export function withDevIdentity(worker, { bindings = env => env, config: authCon
|
|
|
50
50
|
if (!gate || env.RER_DEV_IDENTITY === 'off' || !await gate.admits(request)) return devNotFound();
|
|
51
51
|
const context = identity(env, authConfig, declared);
|
|
52
52
|
try {
|
|
53
|
-
return path === '/__dev/personas' ? Response.json(context.list(), { headers }) : await enter(request, gate, context);
|
|
53
|
+
return path === '/__dev/personas' ? Response.json(await context.list(), { headers }) : await enter(request, gate, context);
|
|
54
54
|
} catch (error) {
|
|
55
55
|
if (error instanceof AuthError) return Response.json({ error: error.code }, { status: error.status, headers });
|
|
56
56
|
throw error;
|
|
@@ -68,7 +68,16 @@ export function withDevIdentity(worker, { bindings = env => env, config: authCon
|
|
|
68
68
|
function identity(env, authConfig, declared) {
|
|
69
69
|
const config = configure(authConfig(env)), store = new AuthStore(env.DB);
|
|
70
70
|
const hash = token => hashToken(config.SESSION_SECRET, 'session', token);
|
|
71
|
-
|
|
71
|
+
// The `owner` persona is the owner rer setup claimed while that owner is
|
|
72
|
+
// active; after that owner has left, the earliest-created active owner; with
|
|
73
|
+
// no active owner left, nobody (email null), so rer names who it signs in as.
|
|
74
|
+
const owner = async () => {
|
|
75
|
+
const setup = await store.byEmail(config.OWNER_EMAIL);
|
|
76
|
+
if (setup?.role === 'owner' && isActiveOperator(setup, config.allowList)) return setup.email;
|
|
77
|
+
const rows = (await store.statement(`SELECT email FROM oa_operators WHERE role='owner' AND status='active' ORDER BY created_at, id`).all()).results;
|
|
78
|
+
return rows.find(row => isActiveOperator({ ...row, status: 'active' }, config.allowList))?.email ?? (setup ? null : config.OWNER_EMAIL);
|
|
79
|
+
};
|
|
80
|
+
const everyone = async () => [{ as: 'owner', email: await owner() }, ...declared];
|
|
72
81
|
async function current(request) {
|
|
73
82
|
const token = readCookie(request, config);
|
|
74
83
|
if (!token) return null;
|
|
@@ -85,8 +94,8 @@ function identity(env, authConfig, declared) {
|
|
|
85
94
|
if (!defaultRolePolicy({ actor: actor.operator, action: 'operators:invite', role: 'owner' })) throw new AuthError('forbidden', 403);
|
|
86
95
|
return { ...actor, hash: await hash(readCookie(request, config)) };
|
|
87
96
|
},
|
|
88
|
-
list: () => [...everyone().map(({ as, email, role }) => ({ as, email, ...(role ? { role } : {}) })), { as: 'signed-out' }],
|
|
89
|
-
async badge(request) { return { session: await current(request), choices: everyone() }; },
|
|
97
|
+
list: async () => [...(await everyone()).map(({ as, email, role }) => ({ as, email, ...(role ? { role } : {}) })), { as: 'signed-out' }],
|
|
98
|
+
async badge(request) { return { session: await current(request), choices: (await everyone()).filter(persona => persona.email) }; },
|
|
90
99
|
};
|
|
91
100
|
}
|
|
92
101
|
|
|
@@ -104,8 +113,9 @@ async function enter(request, gate, { config, store, hash, current, everyone, ow
|
|
|
104
113
|
reply.set('location', '/');
|
|
105
114
|
return new Response(null, { status: 303, headers: reply });
|
|
106
115
|
}
|
|
107
|
-
const persona = everyone().find(p => p.as === as);
|
|
116
|
+
const persona = (await everyone()).find(p => p.as === as);
|
|
108
117
|
if (!persona) return devNotFound();
|
|
118
|
+
if (!persona.email) return text(409, 'No active owner remains in this app. Sign in another way, or run rer reset --yes and rer setup for a new owner.');
|
|
109
119
|
let operator = await store.byEmail(persona.email);
|
|
110
120
|
// Personas are real operators, created only here, in local dev.
|
|
111
121
|
if (!operator && persona.role) {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { deny } from './errors.mjs';
|
|
2
2
|
import { validToken } from './crypto.mjs';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
3
|
+
import { isLoopbackHost } from './loopback.mjs';
|
|
4
|
+
export { isLoopbackHost };
|
|
6
5
|
// Browsers keep cookies per host, not per port, so checkouts side by side on
|
|
7
6
|
// one loopback host would overwrite each other's cookies. A local cookie name
|
|
8
7
|
// therefore carries its origin's port; hosted names never change. Seeds on
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The one loopback predicate every local-only check shares. A local origin's
|
|
2
|
+
// host is one of the three loopback hosts or a name under `.localhost`, which
|
|
3
|
+
// RFC 6761 reserves for loopback and browsers resolve to it themselves: the
|
|
4
|
+
// front door serves each checkout at `http://<name>.localhost:<port>`. Hosted
|
|
5
|
+
// origins never pass through this predicate; they must be https.
|
|
6
|
+
const label = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
7
|
+
const localhostName = new RegExp(`^(?:${label}\\.)+localhost$`);
|
|
8
|
+
|
|
9
|
+
/** A name under `.localhost` (lowercase, no trailing dot), such as `cradle.localhost`. */
|
|
10
|
+
export function isLocalhostName(hostname) {
|
|
11
|
+
return typeof hostname === 'string' && hostname.length <= 253 && localhostName.test(hostname);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `localhost`, `127.0.0.1`, `[::1]` (as URL.hostname spells it) or a `*.localhost` name. */
|
|
15
|
+
export function isLoopbackHost(hostname) {
|
|
16
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || isLocalhostName(hostname);
|
|
17
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Portable Web Crypto verifier. This file has no application or database imports.
|
|
2
|
+
import { isLoopbackHost } from '../operator-auth/loopback.mjs';
|
|
2
3
|
export class OwnerClaimError extends Error {
|
|
3
4
|
constructor(code = 'invalid_claim', status = 403) { super(code); this.name = 'OwnerClaimError'; this.code = code; this.status = status; }
|
|
4
5
|
}
|
|
@@ -10,7 +11,7 @@ function coordinate(value) {
|
|
|
10
11
|
return btoa(atob(value.replaceAll('-', '+').replaceAll('_', '/'))).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') === value;
|
|
11
12
|
}
|
|
12
13
|
function origin(value) {
|
|
13
|
-
try { const u = new URL(value); return u.origin === value && !u.username && !u.password && (u.protocol === 'https:' || (u.protocol === 'http:' &&
|
|
14
|
+
try { const u = new URL(value); return u.origin === value && !u.username && !u.password && (u.protocol === 'https:' || (u.protocol === 'http:' && isLoopbackHost(u.hostname))); } catch { return false; }
|
|
14
15
|
}
|
|
15
16
|
export function configureClaim(input) {
|
|
16
17
|
if (!input || !origin(input.issuer) || !origin(input.appOrigin)
|
|
@@ -217,6 +217,14 @@ and refuses rows matching its tombstones or any destination erasure fence.
|
|
|
217
217
|
Product validation, foreign keys and normal restore safeguards still apply.
|
|
218
218
|
Any mismatch refuses recovery; this mode does not repair damaged archives.
|
|
219
219
|
|
|
220
|
+
Recovery needs a new installation. SQLite, migration and transfer bookkeeping, the
|
|
221
|
+
storage tables of FTS5 indexes (`<index>_data|idx|content|docsize|config`; the index
|
|
222
|
+
itself is still checked through its own SELECT) and the rows the destination's own
|
|
223
|
+
migrations seed may exist; the seed rows are compared with a scratch replay of the
|
|
224
|
+
applied migrations (Node runners) and must be unchanged. Any other row refuses with
|
|
225
|
+
`legacy_target_not_empty`, before staging and again inside the restore batch. A table
|
|
226
|
+
the policy lists in `replaceableTables` is replaced by the restore as usual.
|
|
227
|
+
|
|
220
228
|
V1 cannot authenticate the complete manifest: source identity, export time,
|
|
221
229
|
erasure epoch/journal, rowids and other metadata lack the v2 archive binding.
|
|
222
230
|
Checksums are consistency checks, not proof of authorship. A missing or altered
|
|
@@ -17,9 +17,13 @@ export function base64(bytes) {
|
|
|
17
17
|
for (let i = 0; i < bytes.length; i += 8192) result += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
18
18
|
return btoa(result);
|
|
19
19
|
}
|
|
20
|
+
// Canonical base64 only: a plain character-class scan (no group repetition, so no regex stack) and
|
|
21
|
+
// the padding rule by arithmetic, in time linear in the input however large an original is.
|
|
20
22
|
export function unbase64(value) {
|
|
21
|
-
if (typeof value !== 'string' || !/^
|
|
22
|
-
|
|
23
|
+
if (typeof value !== 'string' || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error('invalid_base64');
|
|
24
|
+
const text = atob(value), bytes = new Uint8Array(text.length);
|
|
25
|
+
for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
|
|
26
|
+
return bytes;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
// Incremental SHA-256 for semantic JSON and objects. SubtleCrypto has no streaming
|
|
@@ -134,7 +134,7 @@ export async function finalizeImport(context,id,{contentHash,archiveHash,laterJo
|
|
|
134
134
|
const digest=verifier.digest();if(contentHash!==undefined)insist(contentHash===digest,'content_checksum');
|
|
135
135
|
insist(archiveHash===archive.digest(),'archive_checksum',409);
|
|
136
136
|
const checked=await fence(db,manifest,row.metadata.copies,laterJournal);
|
|
137
|
-
if(row.metadata.legacyRecovery)await requireLegacyEmpty(context,row.metadata.legacyRecovery
|
|
137
|
+
if(row.metadata.legacyRecovery)await requireLegacyEmpty(context,row.metadata.legacyRecovery);
|
|
138
138
|
for(const table of row.metadata.targets) if(!config.replaceableTables.includes(table.name))insist(!(await statement(db,`SELECT 1 FROM ${quote(table.name)} LIMIT 1`).first()),'target_not_empty:'+table.name,409);
|
|
139
139
|
const dependentGuards=await excludedDependentGuards(db,row.metadata.targets,config.replaceableTables);
|
|
140
140
|
await bucketsEmpty(context,row);
|
|
@@ -4,7 +4,7 @@ import { createPortability } from './index.mjs';
|
|
|
4
4
|
import { createImport, uploadPart, finalizeImport, abortImport } from './import.mjs';
|
|
5
5
|
import { readLegacyArchive, ArchiveVerifier } from './format.mjs';
|
|
6
6
|
import { fileRecords, exportArchive } from './files.mjs';
|
|
7
|
-
import { ensureLegacyAudit,
|
|
7
|
+
import { ensureLegacyAudit, legacyTargetState, requireLegacyEmpty } from './legacy-safety.mjs';
|
|
8
8
|
import { insist, PortabilityError } from './storage.mjs';
|
|
9
9
|
|
|
10
10
|
export async function recoverLegacyArchive({db,buckets,policy:input,migrations},path,output,transient,acknowledged) {
|
|
@@ -13,8 +13,8 @@ export async function recoverLegacyArchive({db,buckets,policy:input,migrations},
|
|
|
13
13
|
insist(!(await lstat(output).catch(error=>{if(error.code==='ENOENT')return null;throw error;})),'legacy_output_exists',409);
|
|
14
14
|
// Whole-file verification precedes even staging/audit schema writes.
|
|
15
15
|
for await(const record of fileRecords(path,readLegacyArchive)){}
|
|
16
|
-
const context={db,buckets,config:policy(input),migrations},
|
|
17
|
-
await requireLegacyEmpty(context,
|
|
16
|
+
const context={db,buckets,config:policy(input),migrations},target=await legacyTargetState(db,migrations);
|
|
17
|
+
await requireLegacyEmpty(context,target);
|
|
18
18
|
let id,manifest,closing,integrity,status,complete=false;
|
|
19
19
|
try {
|
|
20
20
|
for await(const record of fileRecords(path,readLegacyArchive)) {
|
|
@@ -25,7 +25,7 @@ export async function recoverLegacyArchive({db,buckets,policy:input,migrations},
|
|
|
25
25
|
id=await createImport(context,manifest,'legacy-v1',{
|
|
26
26
|
event:'legacy_v1_unverified_restore',acknowledgedUnverifiedMetadata:true,
|
|
27
27
|
sourceIdentity:record.sourceIdentity,exportedAt:record.exportedAt,
|
|
28
|
-
recoveryOutput:output,
|
|
28
|
+
recoveryOutput:output,...target,
|
|
29
29
|
});
|
|
30
30
|
// A refused schema writes nothing, not even the audit table.
|
|
31
31
|
await ensureLegacyAudit(db);
|
|
@@ -2,18 +2,59 @@ import { all, statement, guard, quote, insist } from './storage.mjs';
|
|
|
2
2
|
import { canonicalJson } from './hash.mjs';
|
|
3
3
|
|
|
4
4
|
// Recovery requires a new installation, including excluded identity tables and
|
|
5
|
-
// normally replaceable product rows. Only SQLite/migration/transfer bookkeeping
|
|
6
|
-
//
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
// normally replaceable product rows. Only SQLite/migration/transfer bookkeeping,
|
|
6
|
+
// the storage of FTS5 indexes and rows the destination's own migrations seed may
|
|
7
|
+
// already exist. Do not use the product's export exclusions for this check.
|
|
8
|
+
const bookkeeping=name=>/^(sqlite_|_cf_|rer_portability_)/i.test(name) || ['d1_migrations','_rer_deploy_migrations','_rer_portability_migrations'].includes(name.toLowerCase());
|
|
9
|
+
const fts5=/^\s*CREATE\s+VIRTUAL\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"((?:[^"]|"")+)"|`([^`]+)`|\[([^\]]+)\]|'((?:[^']|'')+)'|([A-Za-z0-9_$]+))\s+USING\s+fts5\b/i;
|
|
10
|
+
const unquoted=m=>m[1]!==undefined?m[1].replaceAll('""','"'):m[4]!==undefined?m[4].replaceAll("''","'"):m[2]??m[3]??m[5];
|
|
11
|
+
// An FTS5 index keeps its data in <name>_data|idx|content|docsize|config. The index itself stays
|
|
12
|
+
// listed and is checked through its own SELECT, so a real indexed row still refuses.
|
|
13
|
+
function shadowTables(tables) {
|
|
14
|
+
const shadows=new Set();
|
|
15
|
+
for(const table of tables) {
|
|
16
|
+
const match=fts5.exec(table.sql);
|
|
17
|
+
if(match)for(const suffix of ['data','idx','content','docsize','config'])shadows.add(unquoted(match).toLowerCase()+'_'+suffix);
|
|
18
|
+
}
|
|
19
|
+
return shadows;
|
|
10
20
|
}
|
|
11
|
-
|
|
12
|
-
|
|
21
|
+
const imageSql=(table,columns)=>`SELECT json_array(${columns.map(c=>`quote(${quote(c)})`).join(',')}) AS image FROM ${quote(table)}`;
|
|
22
|
+
const MAX_SEED_ROWS=10000;
|
|
23
|
+
|
|
24
|
+
/** What the destination must still hold when the restore commits: `emptyTables` stay empty and
|
|
25
|
+
* each of `seededTables` keeps exactly the rows its migrations put there. A table with rows the
|
|
26
|
+
* replay of the destination's applied migrations (migrations(), a node runner's scratch database)
|
|
27
|
+
* does not produce is listed as to-be-empty, so the preflight refuses it. */
|
|
28
|
+
export async function legacyTargetState(db,migrations) {
|
|
29
|
+
const tables=await all(db,"SELECT name,sql FROM sqlite_master WHERE type='table' AND sql IS NOT NULL ORDER BY name"),shadows=shadowTables(tables);
|
|
30
|
+
const names=tables.map(t=>t.name).filter(name=>!bookkeeping(name) && !shadows.has(name.toLowerCase()));
|
|
31
|
+
const filled=[];
|
|
32
|
+
for(const name of names)if(await statement(db,`SELECT 1 FROM ${quote(name)} LIMIT 1`).first())filled.push(name);
|
|
33
|
+
const seeded=[];
|
|
34
|
+
if(filled.length && migrations) {
|
|
35
|
+
const requests=[];
|
|
36
|
+
for(const name of filled)requests.push({name,columns:(await all(db,`SELECT name FROM pragma_table_info(?)`,name)).map(c=>c.name)});
|
|
37
|
+
// Without a usable replay no seed is vouched for, so those rows count as the installation's own.
|
|
38
|
+
const expected=await migrations().then(replay=>replay.rowImages(requests,MAX_SEED_ROWS),()=>new Map());
|
|
39
|
+
for(const {name,columns} of requests) {
|
|
40
|
+
const want=expected.get(name);
|
|
41
|
+
if(!want || (await statement(db,`SELECT count(*) AS n FROM ${quote(name)}`).first()).n!==want.length)continue;
|
|
42
|
+
const found=(await all(db,imageSql(name,columns))).map(r=>r.image).sort();
|
|
43
|
+
if(canonicalJson(found)===canonicalJson(want))seeded.push({name,columns,images:found});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {emptyTables:names.filter(name=>!seeded.some(s=>s.name===name)),seededTables:seeded};
|
|
47
|
+
}
|
|
48
|
+
const seededCondition=(db,table)=>[`(SELECT count(*) FROM ${quote(table.name)})=? AND NOT EXISTS(SELECT 1 FROM (${imageSql(table.name,table.columns)}) WHERE image NOT IN (SELECT value FROM json_each(?)))`,table.images.length,JSON.stringify(table.images)];
|
|
49
|
+
export async function requireLegacyEmpty({db,buckets},{emptyTables,seededTables=[]}) {
|
|
50
|
+
for(const table of emptyTables)insist(!(await statement(db,`SELECT 1 FROM ${quote(table)} LIMIT 1`).first()),'legacy_target_not_empty:'+table,409);
|
|
51
|
+
for(const table of seededTables){const [condition,...values]=seededCondition(db,table);insist((await statement(db,`SELECT CASE WHEN (${condition}) THEN 1 ELSE 0 END AS ok`,...values).first()).ok===1,'legacy_target_not_empty:'+table.name,409);}
|
|
13
52
|
for(const [name,bucket] of Object.entries(buckets))insist(!(await bucket.list({limit:1})).objects.length,'legacy_bucket_not_empty:'+name,409);
|
|
14
53
|
}
|
|
15
54
|
export function legacyEmptyGuards(db,row) {
|
|
16
|
-
|
|
55
|
+
const recovery=row.metadata.legacyRecovery;
|
|
56
|
+
return [...(recovery?.emptyTables??[]).map(table=>guard(db,`NOT EXISTS(SELECT 1 FROM ${quote(table)})`)),
|
|
57
|
+
...(recovery?.seededTables??[]).map(table=>{const [condition,...values]=seededCondition(db,table);return guard(db,condition,...values);})];
|
|
17
58
|
}
|
|
18
59
|
export async function ensureLegacyAudit(db) {
|
|
19
60
|
// Additive sidecar, installed only by recovery; existing product migrations
|
|
@@ -22,6 +63,6 @@ export async function ensureLegacyAudit(db) {
|
|
|
22
63
|
}
|
|
23
64
|
export function legacyAuditStatements(db,row) {
|
|
24
65
|
if(!row.metadata.legacyRecovery)return [];
|
|
25
|
-
const {emptyTables,...details}=row.metadata.legacyRecovery;
|
|
26
|
-
return [statement(db,'INSERT INTO rer_portability_recovery_audit VALUES(?,?,?)',row.id,new Date().toISOString(),canonicalJson({...details,contentHash:row.metadata.contentHash,...(row.metadata.bridge?{bridged:row.metadata.bridge}:{})}))];
|
|
66
|
+
const {emptyTables,seededTables=[],...details}=row.metadata.legacyRecovery;
|
|
67
|
+
return [statement(db,'INSERT INTO rer_portability_recovery_audit VALUES(?,?,?)',row.id,new Date().toISOString(),canonicalJson({...details,...(seededTables.length?{seededTables:seededTables.map(t=>t.name)}:{}),contentHash:row.metadata.contentHash,...(row.metadata.bridge?{bridged:row.metadata.bridge}:{})}))];
|
|
27
68
|
}
|
|
@@ -14,5 +14,20 @@ export function migrationReplay(plan,input) {
|
|
|
14
14
|
for(const migration of plan.filter(m=>names.includes(m.name)))for(const sql of migration.statements)scratch.exec(sql);
|
|
15
15
|
return await schemaIdentity(db,config);
|
|
16
16
|
}finally{scratch.close();}
|
|
17
|
+
},
|
|
18
|
+
// The rows the whole replay leaves in each requested table, as sorted images (legacy-safety.mjs).
|
|
19
|
+
// A table the replay lacks, or one holding more than `limit` rows, has no entry.
|
|
20
|
+
async rowImages(requests,limit) {
|
|
21
|
+
const scratch=new DatabaseSync(':memory:'),found=new Map();
|
|
22
|
+
try {
|
|
23
|
+
for(const migration of plan)for(const sql of migration.statements)scratch.exec(sql);
|
|
24
|
+
for(const {name,columns} of requests) {
|
|
25
|
+
try {
|
|
26
|
+
const rows=scratch.prepare(`SELECT json_array(${columns.map(c=>`quote("${c.replaceAll('"','""')}")`).join(',')}) AS image FROM "${name.replaceAll('"','""')}" LIMIT ?`).all(limit+1).map(r=>r.image);
|
|
27
|
+
if(rows.length<=limit)found.set(name,rows.sort());
|
|
28
|
+
}catch{}
|
|
29
|
+
}
|
|
30
|
+
return found;
|
|
31
|
+
}finally{scratch.close();}
|
|
17
32
|
}};
|
|
18
33
|
}
|
|
@@ -55,9 +55,11 @@ default verification succeeds. Keep capacity, maximum-size memory and load cases
|
|
|
55
55
|
in that release-only script. Both dry runs and releases require it to pass;
|
|
56
56
|
`gates.verify.release` records its result. Routine CI and default verification
|
|
57
57
|
do not invoke it.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
A passing step's receipt retains exit status and numeric final summaries from
|
|
59
|
+
Node's spec/TAP reporters and Playwright's text reporters. A failing install,
|
|
60
|
+
`verify` or `verify:release` step also keeps `output`: the last 40 lines (at most
|
|
61
|
+
8 KB) of that child's output, after the command store's credential redaction.
|
|
62
|
+
The output stays in the receipt and is left out of the site preview. Counts add across suites: `tests`, `passed`,
|
|
61
63
|
`failed`, `skipped` and `flaky`; nonzero `cancelled`, `todo`, `interrupted` and
|
|
62
64
|
`notRun` counts are retained when reported. Playwright's total includes each
|
|
63
65
|
outcome once, with flaky tests counted separately from passed tests. Node's
|
|
@@ -8,6 +8,9 @@ import { publicationScan, validatePublicationDestination } from './publication-s
|
|
|
8
8
|
import { publicDestination, requirePublicDestination, remotePublicHead, publicChild, assertPublicTree } from './public-child.mjs';
|
|
9
9
|
import { writePublishHandoff } from './publish-handoff.mjs';
|
|
10
10
|
|
|
11
|
+
// Failure output belongs to the private receipt, not the site preview.
|
|
12
|
+
const withoutOutput = gates => JSON.parse(JSON.stringify(gates, (key, value) => key === 'output' ? undefined : value));
|
|
13
|
+
|
|
11
14
|
export async function release({ repo, ref = 'HEAD', output, project, repository, dryRun = false, tag = false, cleanHistory: clean = false, envFile, onto }) {
|
|
12
15
|
if (!/^[\w.-]+\/[\w.-]+$/.test(project ?? '')) throw Error('--project must be publisher/name');
|
|
13
16
|
if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+(?:\.git)?$/i.test(repository ?? '')) throw Error('--repository must be a credential-free GitHub repository URL');
|
|
@@ -37,7 +40,7 @@ export async function release({ repo, ref = 'HEAD', output, project, repository,
|
|
|
37
40
|
// Honour the seed's committed install policy. Base's native-build settings
|
|
38
41
|
// are not interchangeable with pnpm 8's locked onlyBuiltDependencies.
|
|
39
42
|
const install = await command('pnpm', ['install', '--frozen-lockfile'], checkout);
|
|
40
|
-
const verify = install.status === 'PASS' ? await command('pnpm', ['run', 'verify'], checkout) : { status: 'FAIL', reason: 'frozen-install-failed', tests: 0, passed: 0, failed: 0 };
|
|
43
|
+
const verify = install.status === 'PASS' ? await command('pnpm', ['run', 'verify'], checkout) : { status: 'FAIL', reason: 'frozen-install-failed', tests: 0, passed: 0, failed: 0, output: install.output };
|
|
41
44
|
// Release-only scale and capacity cases stay outside routine CI. The
|
|
42
45
|
// committed seed script is an additional gate in previews and releases.
|
|
43
46
|
if (verify.status === 'PASS' && JSON.parse(source.bytes('package.json')).scripts['verify:release']) {
|
|
@@ -101,7 +104,7 @@ export async function release({ repo, ref = 'HEAD', output, project, repository,
|
|
|
101
104
|
// A blocked preview may prepare the site metadata/docs for local review.
|
|
102
105
|
// Its provenance explicitly records blocked gates; it is never a release.
|
|
103
106
|
const publicSource = exported ? { ...source, commit: exported.commit, publicTagStaged: Boolean(destination && tag) } : source;
|
|
104
|
-
if (passed || preview) receipt.siteRelease = writeCatalogue(publicSource, output, catalogue(publicSource, project, repository, gates, preview));
|
|
107
|
+
if (passed || preview) receipt.siteRelease = writeCatalogue(publicSource, output, catalogue(publicSource, project, repository, withoutOutput(gates), preview));
|
|
105
108
|
if (passed) {
|
|
106
109
|
if (tag) {
|
|
107
110
|
receipt.publicExport = publicExport(source, temporary, output);
|
|
@@ -4,6 +4,7 @@ import { shared } from '../../cli/shared.mjs';
|
|
|
4
4
|
import { execFileSync, spawn } from 'node:child_process';
|
|
5
5
|
import { realpathSync, existsSync, lstatSync, mkdirSync } from 'node:fs';
|
|
6
6
|
import { resolve, dirname, relative, isAbsolute } from 'node:path';
|
|
7
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
7
8
|
import { testSummary } from './test-summary.mjs';
|
|
8
9
|
|
|
9
10
|
export function git(repo, ...args) {
|
|
@@ -56,16 +57,26 @@ export function newOutput(repo, output) {
|
|
|
56
57
|
return output;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
60
|
+
// Child output is redacted by the command store before it is kept. A failing
|
|
61
|
+
// step's receipt carries the last 40 lines of it, at most 8 KB; a passing step
|
|
62
|
+
// carries only its exit status and numeric test summaries.
|
|
63
|
+
const TAIL_LINES = 40, TAIL_BYTES = 8192;
|
|
64
|
+
function failureTail(logs) {
|
|
65
|
+
const lines = stripVTControlCharacters(logs.join('\n')).split('\n');
|
|
66
|
+
while (lines.length && lines.at(-1).trim() === '') lines.pop();
|
|
67
|
+
const bytes = Buffer.from(lines.slice(-TAIL_LINES).join('\n'));
|
|
68
|
+
return bytes.subarray(Math.max(0, bytes.length - TAIL_BYTES)).toString('utf8').replace(/^\uFFFD+/, '');
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
export async function command(command, args, cwd) {
|
|
62
72
|
const {createCommands}=await shared('commands.mjs');
|
|
63
73
|
const directory=await mkdtemp(resolve(tmpdir(),'rer-release-'));
|
|
64
|
-
const
|
|
74
|
+
const state={logs:[]};
|
|
75
|
+
const store=await createCommands(directory,state,{terminal:()=>{},queue:{verb:'release'}});
|
|
65
76
|
const summary=testSummary(),env={...process.env,FORCE_COLOR:'0'};delete env.NODE_TEST_CONTEXT;
|
|
66
77
|
try {
|
|
67
78
|
await store.run('Release verification',command,args,{cwd,env,onChild:child=>child.stdout.on('data',bytes=>summary.write(bytes))});
|
|
68
79
|
return {status:'PASS',exitCode:0,...summary.finish()};
|
|
69
|
-
}catch(error){return {status:'FAIL',exitCode:error.childExitCode??null,...summary.finish()};}
|
|
80
|
+
}catch(error){return {status:'FAIL',exitCode:error.childExitCode??null,...summary.finish(),output:failureTail(state.logs)};}
|
|
70
81
|
finally{await store.closeCommands();await rm(directory,{recursive:true,force:true});}
|
|
71
82
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { approveSetupDependencies } from './dependency-approval.mjs';
|
|
2
|
+
import { appFetch } from './app-fetch.mjs';
|
|
2
3
|
import { createHmac } from 'node:crypto';
|
|
3
4
|
import {commandFailure,installationFailure} from './failures.mjs';
|
|
4
5
|
import { labels } from './adapter-labels.mjs';
|
|
@@ -10,10 +11,10 @@ const outboxPath = '/__dev/outbox'; // modules/mail/src/development default path
|
|
|
10
11
|
// that is not in local mode ignores the header. The proof comes from the
|
|
11
12
|
// setting the seed's adapter declares as its session secret (sessionSecretKey,
|
|
12
13
|
// default SESSION_SECRET).
|
|
13
|
-
const localCliProof = secret => createHmac('sha256', secret).update('local-cli\0sign-in').digest('hex');
|
|
14
|
+
export const localCliProof = secret => createHmac('sha256', secret).update('local-cli\0sign-in').digest('hex');
|
|
14
15
|
const post = (context, path, body) => {
|
|
15
16
|
const secret = context.vars[context.sessionSecretKey ?? 'SESSION_SECRET'];
|
|
16
|
-
return
|
|
17
|
+
return appFetch(new URL(path, context.origin), {
|
|
17
18
|
method: 'POST', redirect: 'manual', signal: context.signal,
|
|
18
19
|
headers: { Origin: context.origin, 'Content-Type': 'application/json', ...(typeof secret === 'string' && secret ? { 'x-rer-local-cli': localCliProof(secret) } : {}) }, body: JSON.stringify(body),
|
|
19
20
|
});
|
|
@@ -75,7 +76,7 @@ export async function signIn(context, hook) {
|
|
|
75
76
|
async function outboxMessages(context, path, address, required = false) {
|
|
76
77
|
let response;
|
|
77
78
|
// A dev-identity seed gates its outbox with the checkout's key; present it.
|
|
78
|
-
try { response = await
|
|
79
|
+
try { response = await appFetch(new URL(path, context.origin), { signal: context.signal, headers: context.devKey ? { 'x-rer-dev-key': context.devKey } : {} }); } catch (error) {
|
|
79
80
|
if (context.signal?.aborted) throw error;
|
|
80
81
|
if(required){context.store.report?.('Local development outbox is unavailable. Run rer setup before requesting sign-in.');throw context.fail('setup');}
|
|
81
82
|
return [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { createServer } from 'node:net';
|
|
3
|
-
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { labels } from './adapter-labels.mjs';
|
|
5
6
|
import { credentialValues } from './redaction.mjs';
|
|
6
7
|
import { privateDirectory, readPrivateFile, writePrivateFile } from './private-files.mjs';
|
|
@@ -8,6 +9,9 @@ import { localVariables } from './local/environment.mjs';
|
|
|
8
9
|
import { assertRegular, inspect } from './local/files.mjs';
|
|
9
10
|
import { localOperation } from './local/operation.mjs';
|
|
10
11
|
import { recoverInterrupted } from './local/recovery.mjs';
|
|
12
|
+
// The shared loopback predicate, beside this copy in Base or in a seed's scripts/.
|
|
13
|
+
const portable = basename(dirname(fileURLToPath(import.meta.url))) === 'base-onboarding';
|
|
14
|
+
const { isLocalhostName } = await import(new URL(portable ? '../base-cli/modules/operator-auth/loopback.mjs' : '../modules/operator-auth/loopback.mjs', import.meta.url));
|
|
11
15
|
|
|
12
16
|
async function freePort() {
|
|
13
17
|
const socket = createServer();
|
|
@@ -17,15 +21,16 @@ async function freePort() {
|
|
|
17
21
|
return port;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
// A rerun keeps the loopback origin already saved in the seed's settings
|
|
21
|
-
//
|
|
24
|
+
// A rerun keeps the loopback origin already saved in the seed's settings (a
|
|
25
|
+
// 127.0.0.1 port or a front-door `NAME.localhost` origin), so sessions, links
|
|
26
|
+
// and the owner's bookmarks stay valid.
|
|
22
27
|
async function chooseOrigin(context, hooks) {
|
|
23
28
|
if (context.selectedOrigin) return context.selectedOrigin;
|
|
24
29
|
if (typeof hooks.origin === 'function') return hooks.origin(context);
|
|
25
30
|
const key = hooks.secrets?.originKey;
|
|
26
31
|
if (key) {
|
|
27
32
|
const saved = (await readSettings(context.root, hooks.secrets)).values[key];
|
|
28
|
-
try { const url = new URL(saved); if (url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.port && url.origin === saved) return saved; } catch {}
|
|
33
|
+
try { const url = new URL(saved); if (url.protocol === 'http:' && (url.hostname === '127.0.0.1' || isLocalhostName(url.hostname)) && url.port && url.origin === saved) return saved; } catch {}
|
|
29
34
|
}
|
|
30
35
|
return `http://127.0.0.1:${await freePort()}`;
|
|
31
36
|
}
|
|
@@ -110,7 +115,18 @@ async function writeSecrets(context, options) {
|
|
|
110
115
|
async function moveSavedOrigin(context, options, values) {
|
|
111
116
|
const key = options.originKey ?? 'AUTH_ORIGIN', origin = context.selectedOrigin;
|
|
112
117
|
if (!origin || values[key] === undefined || values[key] === origin) return values;
|
|
113
|
-
|
|
118
|
+
return rewriteOrigin(context.root, options, values, origin);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Rewrite only the saved origin (rer dev --door and --direct). The caller holds
|
|
122
|
+
// the local operation lock.
|
|
123
|
+
export async function replaceSavedOrigin(root, options, origin) {
|
|
124
|
+
return rewriteOrigin(root, options, (await readSettings(root, options)).values, origin);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function rewriteOrigin(root, options, values, origin) {
|
|
128
|
+
const key = options.originKey ?? 'AUTH_ORIGIN';
|
|
129
|
+
const current = await readSettings(root, options), format = options.format ?? 'dotenv', merged = { ...values, [key]: origin };
|
|
114
130
|
const text = format === 'json' ? JSON.stringify(merged, null, 1) + '\n' : current.text.replace(new RegExp(`^${key}=.*$`, 'm'), `${key}=${dotenvValue(origin)}`);
|
|
115
131
|
if (format !== 'json' && localVariables(text)[key] !== origin) throw new Error(`Could not move ${key} in ${options.file ?? '.dev.vars'}; put ${key}=${origin} on its own line.`);
|
|
116
132
|
await writePrivateFile(current.path, text);
|
package/onboarding/adapter.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { credentialValues } from './redaction.mjs';
|
|
|
10
10
|
import { labels } from './adapter-labels.mjs';
|
|
11
11
|
import { chooseOrigin, prepareSettings, readSettings } from './adapter-settings.mjs';
|
|
12
12
|
import {listenerPorts} from './process-diagnostics.mjs';
|
|
13
|
+
import { appFetch } from './app-fetch.mjs';
|
|
13
14
|
import { owner, signIn } from './adapter-auth.mjs';
|
|
14
15
|
import { installerDevKey } from './dev-identity.mjs';
|
|
15
16
|
import { assertArchiveFree } from './archive-output.mjs';
|
|
@@ -180,7 +181,7 @@ async function build(context, hooks) {
|
|
|
180
181
|
// Health is the application's own /health: HTTP 200 with JSON status "ok"
|
|
181
182
|
// unless the seed supplies another check. Each probe honours its signal.
|
|
182
183
|
export async function defaultHealth(url, signal) {
|
|
183
|
-
const response = await
|
|
184
|
+
const response = await appFetch(url, { signal });
|
|
184
185
|
if (!response.ok) {
|
|
185
186
|
await response.body?.cancel();
|
|
186
187
|
return { ready: false, message: `HTTP ${response.status}` };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Requests rer itself makes to a local app's own origin (health, sign-in,
|
|
2
|
+
// outbox, dev identity). A directly served origin uses fetch. A front-door
|
|
3
|
+
// origin (http://NAME.localhost:PORT) is never resolved and never goes through
|
|
4
|
+
// the door: rer connects with node:http to that checkout's own proxy on
|
|
5
|
+
// 127.0.0.1, which keeps the Host header (fetch drops a Host override, and musl
|
|
6
|
+
// and some Linux resolvers do not resolve *.localhost), and adds the proxy's
|
|
7
|
+
// per-start secret. Redirects within the routed origin are followed the way
|
|
8
|
+
// fetch follows them. A `*.localhost` origin with no route (its server is not
|
|
9
|
+
// running in this process's knowledge) is refused before anything is sent:
|
|
10
|
+
// whatever answers that name (the door, or a process that took its port) never
|
|
11
|
+
// receives rer's own requests, which can carry the checkout's dev key.
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import { basename, dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
// The shared loopback predicate, beside this copy in Base or in a seed's scripts/.
|
|
16
|
+
const portable = basename(dirname(fileURLToPath(import.meta.url))) === 'base-onboarding';
|
|
17
|
+
const { isLocalhostName } = await import(new URL(portable ? '../base-cli/modules/operator-auth/loopback.mjs' : '../modules/operator-auth/loopback.mjs', import.meta.url));
|
|
18
|
+
|
|
19
|
+
const routes = new Map();
|
|
20
|
+
|
|
21
|
+
/** Send this process's requests for `origin` to 127.0.0.1:`port` with `headers` added. */
|
|
22
|
+
export function routeOrigin(origin, { port, headers = {} }) {
|
|
23
|
+
routes.set(new URL(origin).origin, { port, headers });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One HTTP/1.1 exchange with a loopback listener, Host as given. */
|
|
27
|
+
export function loopbackRequest({ host = '127.0.0.1', port, path = '/', method = 'GET', headers = {}, body, signal, timeoutMs = 30000 }) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const request = http.request({ host, port, path, method, headers, agent: false, signal }, response => {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
response.on('data', chunk => chunks.push(chunk));
|
|
32
|
+
response.on('end', () => resolve({ status: response.statusCode, statusText: response.statusMessage ?? '', rawHeaders: response.rawHeaders, body: Buffer.concat(chunks) }));
|
|
33
|
+
response.on('error', reject);
|
|
34
|
+
});
|
|
35
|
+
request.setTimeout(timeoutMs, () => request.destroy(Object.assign(Error(`No answer from ${host}:${port} within ${timeoutMs / 1000} s.`), { code: 'ETIMEDOUT' })));
|
|
36
|
+
request.on('error', reject);
|
|
37
|
+
request.end(body);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function bodyBytes(body) {
|
|
42
|
+
if (body === undefined || body === null) return undefined;
|
|
43
|
+
if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) return Buffer.from(body);
|
|
44
|
+
return Buffer.from(await new Response(body).arrayBuffer());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const nullBody = status => [101, 103, 204, 205, 304].includes(status);
|
|
48
|
+
|
|
49
|
+
function unrouted(url) {
|
|
50
|
+
return Object.assign(new TypeError(`fetch failed: rer sends nothing to ${url.origin} except through that checkout's own server, which is not running here.`), { code: 'ERR_RER_UNROUTED' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function appFetch(input, init = {}) {
|
|
54
|
+
let url = new URL(input instanceof Request ? input.url : String(input));
|
|
55
|
+
if (!routes.has(url.origin) && isLocalhostName(url.hostname)) throw unrouted(url);
|
|
56
|
+
if (!routes.has(url.origin)) return fetch(input, init);
|
|
57
|
+
let method = (init.method ?? 'GET').toUpperCase(), body = await bodyBytes(init.body);
|
|
58
|
+
const sent = Object.fromEntries(new Headers(init.headers ?? {}));
|
|
59
|
+
if (body !== undefined && init.body instanceof URLSearchParams && !sent['content-type']) sent['content-type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
|
60
|
+
for (let hops = 0; ; hops++) {
|
|
61
|
+
const route = routes.get(url.origin);
|
|
62
|
+
if (!route && isLocalhostName(url.hostname)) throw unrouted(url);
|
|
63
|
+
if (!route) return fetch(url, { ...init, method, body });
|
|
64
|
+
const reply = await loopbackRequest({ port: route.port, path: url.pathname + url.search, method, headers: { ...sent, ...route.headers, host: url.host, ...(body ? { 'content-length': String(body.length) } : {}) }, body, signal: init.signal });
|
|
65
|
+
const headers = new Headers();
|
|
66
|
+
for (let i = 0; i < reply.rawHeaders.length; i += 2) headers.append(reply.rawHeaders[i], reply.rawHeaders[i + 1]);
|
|
67
|
+
const location = headers.get('location');
|
|
68
|
+
if (location && [301, 302, 303, 307, 308].includes(reply.status) && (init.redirect ?? 'follow') !== 'manual') {
|
|
69
|
+
if (init.redirect === 'error') throw new TypeError('fetch failed: redirect');
|
|
70
|
+
if (hops >= 20) throw new TypeError('fetch failed: too many redirects');
|
|
71
|
+
if (reply.status === 303 || ([301, 302].includes(reply.status) && method === 'POST')) { method = 'GET'; body = undefined; delete sent['content-type']; }
|
|
72
|
+
url = new URL(location, url);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const response = new Response(nullBody(reply.status) || method === 'HEAD' ? null : reply.body, { status: reply.status, statusText: reply.statusText, headers });
|
|
76
|
+
Object.defineProperty(response, 'url', { value: url.href });
|
|
77
|
+
return response;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
|
-
|
|
3
|
-
export
|
|
2
|
+
/** `formats`: the seed's own archive formats, matched against a leading JSON object's `payload.format`. */
|
|
3
|
+
export interface ArchiveFormats { formats?: string[] }
|
|
4
|
+
export function assertArchiveFree(directory: string, options?: ArchiveFormats): Promise<void>;
|
|
5
|
+
export function archiveOutputGuard(options?: ArchiveFormats): Plugin;
|