@jimhoyd/urlcode 0.4.1 → 0.4.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.
- package/.claude/skills/urlcode-authoring/SKILL.md +10 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/CONTRIBUTING.md +36 -0
- package/ROADMAP.md +16 -12
- package/dist/BUILD-MANIFEST.json +6 -6
- package/dist/cli.js +2 -1
- package/dist/extensions.js +78 -1
- package/dist/mcp.js +1 -1
- package/dist/policy.js +16 -0
- package/dist/readiness.js +5 -1
- package/dist/tooling.js +2 -1
- package/dist/types/extensions.d.ts +58 -0
- package/dist/types/readiness.d.ts +3 -0
- package/dist/types/tooling.d.ts +1 -0
- package/docs/AI-AUTHORING.md +5 -4
- package/docs/COMPOSING-A-SITE.md +15 -6
- package/docs/CONTAINER-PROMOTION.md +74 -0
- package/docs/DEVELOPMENT-PIPELINE.md +39 -5
- package/docs/EXTENSIONS.md +53 -88
- package/docs/FUNCTION-SECURITY.md +5 -8
- package/docs/OPEN-DECISIONS.md +27 -102
- package/docs/READINESS.md +4 -1
- package/docs/README.md +16 -10
- package/docs/RELEASE-0.4.2.md +30 -0
- package/docs/RELEASE-READINESS.md +40 -11
- package/docs/RELEASE-SECURITY.md +6 -2
- package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +9 -13
- package/docs/TOOLING.md +1 -1
- package/docs/VERSION-ALIGNMENT.md +8 -0
- package/docs/archive/2026-09-20/OPEN-DECISIONS-COMPLETED.md +116 -0
- package/docs/archive/README.md +1 -0
- package/llms-full.txt +79 -107
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/packaging/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +10 -0
- package/skills/urlcode/SKILL.md +8 -1
|
@@ -30,6 +30,11 @@ Start with `urlcode context --project <dir> --budget 4000`, then retrieve the
|
|
|
30
30
|
capability, schema fragment, recipe or example relevant to the change. Use the
|
|
31
31
|
read-only MCP equivalents when available. `llms.txt` is the index; read the
|
|
32
32
|
matching task guide from `docs/` when a query needs more explanation.
|
|
33
|
+
When the project has an operator host file, inspect `urlcode extensions
|
|
34
|
+
--project <dir> --host-file <absolute-file> --json` (MCP: `get_extensions`)
|
|
35
|
+
before writing extension configuration or project hooks. The report is the
|
|
36
|
+
machine-readable source for config/policy schemas and hook names, purposes and
|
|
37
|
+
input/output schemas.
|
|
33
38
|
`docs/SPECIFICATION.md` and `schemas/urlcode.schema.json` resolve contract
|
|
34
39
|
questions. Archived plans are historical, not valid YAML guidance.
|
|
35
40
|
|
|
@@ -49,6 +54,11 @@ questions. Archived plans are historical, not valid YAML guidance.
|
|
|
49
54
|
- Create every referenced module, page and asset **before** validating. All
|
|
50
55
|
source paths resolve from the project root. Trusted modules can import Node built-ins and npm packages;
|
|
51
56
|
only `sandbox: true` modules are restricted to the relative snapshotted graph.
|
|
57
|
+
- Customize installed extensions in this order: declarative configuration;
|
|
58
|
+
`urlcode-ui` copy, theme, template and CSS overrides; a hook declared by the
|
|
59
|
+
extension; a new extension only when the installed contract cannot express
|
|
60
|
+
the behavior. Extension hooks run trusted in-process and reject `sandbox:
|
|
61
|
+
true` in contract v1.
|
|
52
62
|
- Write exact response fixtures for success and failure, covering every active
|
|
53
63
|
method, middleware behavior, HEAD, and any range or cache semantics.
|
|
54
64
|
- Follow `docs/BEST-PRACTICES.md` for layout and readability as the project grows.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -79,6 +79,42 @@ A behavior change that a reader depends on is not finished until the matching
|
|
|
79
79
|
page in `docs/` is updated. Put both in the **same** pull request so review can
|
|
80
80
|
see both halves and neither can land alone.
|
|
81
81
|
|
|
82
|
+
### Maintain existing pages first
|
|
83
|
+
|
|
84
|
+
Before creating a page, search the docs and update the existing home for the
|
|
85
|
+
reader's task. A new page needs a distinct purpose and a link from the relevant
|
|
86
|
+
guide or index. Routine task summaries and verification transcripts belong in
|
|
87
|
+
the pull request, not a new permanent report.
|
|
88
|
+
|
|
89
|
+
Keep each kind of information in its authoritative home:
|
|
90
|
+
|
|
91
|
+
| Information | Home |
|
|
92
|
+
|---|---|
|
|
93
|
+
| Implemented behavior and accepted fields | Specification, topic contracts and generated schema reference |
|
|
94
|
+
| How to accomplish a task | The existing guide or executable recipe |
|
|
95
|
+
| Current versions and publication state | Manifests and the commands in [version alignment](docs/VERSION-ALIGNMENT.md) |
|
|
96
|
+
| Actionable bugs and proposed work | GitHub issues; the roadmap links to priorities rather than copying task lists |
|
|
97
|
+
| Unresolved design choices | [Open decisions](docs/OPEN-DECISIONS.md), linked to the relevant issue |
|
|
98
|
+
| Security and operational evidence | Dated, scoped evidence records and the readiness register |
|
|
99
|
+
| Completed or superseded plans | [Archive](docs/archive/README.md), with a link to the current owner of any remaining work |
|
|
100
|
+
|
|
101
|
+
Link to these sources instead of copying changing status or entire explanations.
|
|
102
|
+
Examples and brief task-specific explanations are useful; a second maintained
|
|
103
|
+
version table or backlog is not. Do not treat archiving a plan as closing its
|
|
104
|
+
unperformed security or deployment checks.
|
|
105
|
+
|
|
106
|
+
When a decision is implemented, update the reader-facing guide and remove its
|
|
107
|
+
obsolete next steps. Preserve useful rationale and evidence with an explicit
|
|
108
|
+
status; do not leave historical proposals presenting themselves as current
|
|
109
|
+
instructions. Avoid moving files only for tidiness: existing links and anchors
|
|
110
|
+
are part of the documentation interface.
|
|
111
|
+
|
|
112
|
+
Run `npm run check:docs` after documentation changes. It checks local links,
|
|
113
|
+
retired repository references, guidance claims and generated resources. These
|
|
114
|
+
checks cannot prove prose is current: review the affected facts against code
|
|
115
|
+
and evidence too. Reviewers should ask which page owns the changed information
|
|
116
|
+
and whether this PR introduced a competing explanation.
|
|
117
|
+
|
|
82
118
|
`urlcode-docs` was deleted on 2026-09-19. It held its own copy of most of these
|
|
83
119
|
pages and had drifted from them; the content that was ahead has been brought
|
|
84
120
|
across, and the repository was retired rather than reconciled page by page.
|
package/ROADMAP.md
CHANGED
|
@@ -7,7 +7,7 @@ principles; [the specification](docs/SPECIFICATION.md) owns implemented behavior
|
|
|
7
7
|
|
|
8
8
|
## What works now
|
|
9
9
|
|
|
10
|
-
The source
|
|
10
|
+
The current source provides declarative routing, responses, assets, policies,
|
|
11
11
|
conditions, proxy/signals, trusted Node functions and middleware, and opt-in
|
|
12
12
|
`sandbox: true` isolation. Target support differs: use `urlcode capabilities`
|
|
13
13
|
before promising a deployment. Stored short links have no supported package.
|
|
@@ -22,19 +22,19 @@ not future phases. See [the framework](docs/FRAMEWORK.md).
|
|
|
22
22
|
|
|
23
23
|
## Next work
|
|
24
24
|
|
|
25
|
-
Auth, admin and UI are workspace packages here,
|
|
26
|
-
|
|
27
|
-
[
|
|
28
|
-
|
|
29
|
-
middleware
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
Auth, admin and UI are workspace packages here, released from this repository;
|
|
26
|
+
the migration is [done](docs/archive/2026-09-20/OPEN-DECISIONS-COMPLETED.md) and
|
|
27
|
+
its plan is [archived](docs/archive/2026-09-19/SPIKE-MONOREPO.md). The separate
|
|
28
|
+
middleware package was withdrawn rather than migrated
|
|
29
|
+
([the decision](docs/OPEN-DECISIONS.md#accepted-middleware-withdrawn-rather-than-consolidated)).
|
|
30
|
+
Versions and channels are in [version alignment](docs/VERSION-ALIGNMENT.md) and
|
|
31
|
+
`npm run release:status`, not in this page.
|
|
32
32
|
|
|
33
33
|
1. **Make the existing product coherent.** Keep docs, examples, generated LLM
|
|
34
34
|
resources, installed skills and the standalone template consistent with their
|
|
35
35
|
runtime version. Resolve the [open decisions](docs/OPEN-DECISIONS.md).
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
Schema-invalid YAML examples in Markdown are now checked
|
|
37
|
+
(`scripts/check-guidance-claims.ts`; issue 168 is closed).
|
|
38
38
|
[Issue 174](https://github.com/jimhoyd-com/urlcode/issues/174) retains the
|
|
39
39
|
extension-schema retrieval proposal.
|
|
40
40
|
2. **Measure the agent experience.** The benchmark harness and authoring evals
|
|
@@ -47,8 +47,12 @@ there is nothing to move in and nothing to fold into core afterward.
|
|
|
47
47
|
independent security review, deployed recovery/soak tests and real provider
|
|
48
48
|
verification remain distinct from source implementation and local tests.
|
|
49
49
|
[Issue 58](https://github.com/jimhoyd-com/urlcode/issues/58) and
|
|
50
|
-
[release readiness](docs/RELEASE-READINESS.md) retain those gates
|
|
51
|
-
Google/Apple/SES checks remain
|
|
50
|
+
[release readiness](docs/RELEASE-READINESS.md) retain those gates; a stable
|
|
51
|
+
release or green CI does not close them. Live Google/Apple/SES checks remain
|
|
52
|
+
explicitly deferred. [Issue 185](https://github.com/jimhoyd-com/urlcode/issues/185)
|
|
53
|
+
(CI lane measurement and release-train validation) and
|
|
54
|
+
[issue 202](https://github.com/jimhoyd-com/urlcode/issues/202) (Windows auth
|
|
55
|
+
worker startup timeout) are also still open.
|
|
52
56
|
4. **Choose expansion from evidence.** Collections and a business application
|
|
53
57
|
suite are proposals, not available features. Decide scope before implementing
|
|
54
58
|
them. Per-route Lambda compilation is **decided against**: projects using
|
package/dist/BUILD-MANIFEST.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"dist/capabilities.js": "28dba05b3851b8b12b3c1b809af87b732f84d601c737afe9225abe429886e691",
|
|
15
15
|
"dist/capability-query.js": "9f68bc94901451d7b9ecdd3aee8c3376dca7f0b890e31d8205306cf8120dea86",
|
|
16
16
|
"dist/catalog.js": "c378ef9bfd63b940790f6975f7db54557c2459722b65a7c170b97df24184bc52",
|
|
17
|
-
"dist/cli.js": "
|
|
17
|
+
"dist/cli.js": "76d3741fa476ff20c9dae2daff56bc5c51701c7fdd61ac924ed0f3e2809928ca",
|
|
18
18
|
"dist/client-address.js": "9d0d01466aab23124330605c5a0e0f981f87c897858acfad909f546a63df165b",
|
|
19
19
|
"dist/cloudflare.js": "17b43b0a6b6a0ef8a893415c989e4ae3976d97cc966adbf897b785f9adc472e3",
|
|
20
20
|
"dist/compliance-rules/baseline.js": "6296dea6bcb9f9bb80ed6ed5ab2f456e7f74e6de2be5d2d98ca8c983fb4df3ab",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"dist/examples.js": "89ae2b764a688fe52acfb9b94d3d262d7742401ebd0ef10c9ebfc76edfa5631c",
|
|
33
33
|
"dist/explain-cli.js": "f0f8f3b5046430d03c6abb629b38651c9c734b03456040735ce9d7e582b2bcd5",
|
|
34
34
|
"dist/explain.js": "7fb632036cc68280701dd512c74d27d6e0f97b4848e9024b284d7c5393b35eb5",
|
|
35
|
-
"dist/extensions.js": "
|
|
35
|
+
"dist/extensions.js": "07ae8888a5944372ee452b58fb0b6de226b0bc6ed73c5d5a198c1788e1a55fd6",
|
|
36
36
|
"dist/function-sources.js": "16fad4abc81c7ee07b6cbcef2d23a9fc50e97a17cb4dde1db53469bbb555c96f",
|
|
37
37
|
"dist/function-worker.js": "35771790ada4e1b36d447d4967a5e6cf0b543f32944e4c904f5fbf8998e824a5",
|
|
38
38
|
"dist/functions.js": "0f09d1bd27eb655ceb5900a4a5e2b760d2028b02cb637a6a89a4eb5940828008",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"dist/manifest.js": "83eed0d59621039ef364cf6e7b54c5bd47e0d8cf0a5abe7b07344bfc1db56ed8",
|
|
49
49
|
"dist/match.js": "53ebcc2cda529a8d07fb83f8cb69a1446036ffc1641af3c4bd45a55851bded28",
|
|
50
50
|
"dist/mcp-authoring.js": "1183a56c5decb99ecb67a5da7bd91d1b494bc17a2965051aa63c4a7e291462b5",
|
|
51
|
-
"dist/mcp.js": "
|
|
51
|
+
"dist/mcp.js": "4b818de2567ae8fb03adf3e5984931672ee2b469bc7ae9586388552846dae15e",
|
|
52
52
|
"dist/observability.js": "f4b1ab496f051fe3ef2ed36b2b2e469c63a8940700e2df852e6ff2d9cccdd5ae",
|
|
53
53
|
"dist/operator-host.js": "e3dac9d43a83beb775202b4ab9eeeba7ac63cebfc5dcc669be38d825be587bd0",
|
|
54
54
|
"dist/plugins.js": "a61aa933cd9e1b41020b86fae78aa3a7f45f5313124aa11b50cb57898722385e",
|
|
@@ -58,13 +58,13 @@
|
|
|
58
58
|
"dist/policies/security.js": "2b3bfdf82140156fea4b162adc28ee43ccd85c402fb200680dbb8d8706417420",
|
|
59
59
|
"dist/policies/throttle.js": "bbb0cb1c24cf70d0a1ac32fc0618efb6c44e5b5d42564842f9c585ff7e501603",
|
|
60
60
|
"dist/policies.js": "3750a7032ec7326aa544f4808683ad9eb91d4f2c5afe79904420cd8a355710a9",
|
|
61
|
-
"dist/policy.js": "
|
|
61
|
+
"dist/policy.js": "2da95b3f1f77249b1e9f399a06b88909b68594fdbc958176075e91ff23104a7c",
|
|
62
62
|
"dist/prerender.js": "6210999e848ab86c87f265943960ef63716896da7a40e248221a6a30425c56b8",
|
|
63
63
|
"dist/project-dependencies.js": "b43a8f3125f62d38f0e71237b7813a785ef0613a83fcdec3fa8314f33758a534",
|
|
64
64
|
"dist/project-tests.js": "445eaa139d2f1a2534369cfdeef081ef518de03218a467e7c5c2059caf840270",
|
|
65
65
|
"dist/provider-verification.js": "ffcf9f05e8cb5f71ea46a97e279e8a613fdfdf9435ab02bd57af11e2680a9107",
|
|
66
66
|
"dist/proxy.js": "34107e59ac0dbbea090efa3cf5badf3226c5302ba25e8f8ff9b2b5596ed80e50",
|
|
67
|
-
"dist/readiness.js": "
|
|
67
|
+
"dist/readiness.js": "28b1ade5addbc3c7427fe6a52b93850001904187c0e03c1552cbb77d8fb8572d",
|
|
68
68
|
"dist/recipes.js": "11853f23e63c3c65c5bed03a278cde0349d28c9fa23266c9907a2df318bb5fc3",
|
|
69
69
|
"dist/route-diff.js": "8d71d50d332ffaaed588656bd32aa36763260d1ad87bb62d7ea1c8fb23383969",
|
|
70
70
|
"dist/router.js": "cf831025d5ed6778d1d6a0e541177799794e76766c82084ded81d3e87317ca1e",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"dist/server.js": "d878bc8f0d6a05f8fd25997cace3150e2ff4d44ee9c8f4b5530e29601e164be1",
|
|
76
76
|
"dist/signals.js": "b55e54efc8fb6703e08f2a1e808ec8e011bda67738db8ceadf1b248f695795be",
|
|
77
77
|
"dist/site.js": "c40a06a6f9e2d6d3bea94727783c115fed4309bb0190787f58434eed64cbde84",
|
|
78
|
-
"dist/tooling.js": "
|
|
78
|
+
"dist/tooling.js": "191d88eda3bf174f7000fa34995c940f3cf7e9ceafeb4b813e16eb0006767055",
|
|
79
79
|
"dist/trusted-functions.js": "f3800c75f45ce90faf85ba6664398ad9031c5a9ac51d5ce42bdcff08a497d48e",
|
|
80
80
|
"dist/types.js": "2fe7664dbb5695ec72114197abde72de4031df9c3b211740eacefa68e9b21844",
|
|
81
81
|
"dist/typescript-authoring.js": "2e98498a15bdbb76f36fbee84afb7c42ca95bbf80a2e6dd4040147f3b1642a29",
|
package/dist/cli.js
CHANGED
|
@@ -28,7 +28,7 @@ import { loadComplianceRules, profileNames as complianceProfiles } from './compl
|
|
|
28
28
|
import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.js';
|
|
29
29
|
import { readFile } from 'node:fs/promises';
|
|
30
30
|
|
|
31
|
-
const usage = `URLCode 0.4.
|
|
31
|
+
const usage = `URLCode 0.4.2 — local/self-hosted runtime
|
|
32
32
|
urlcode init <directory> [--with ui,auth,admin] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
|
|
33
33
|
# --with: layered site from installed @jimhoyd/urlcode-<name> packages, with a package.json pinning them exactly; name ui first
|
|
34
34
|
# --manifest: also pin the runtime for a route-only project; --no-manifest: --with without a package.json
|
|
@@ -135,6 +135,7 @@ function formatExtensions(report ) {
|
|
|
135
135
|
if (!report.declared.length) lines.push('Declared: (none)');
|
|
136
136
|
for (const item of report.extensions) lines.push(`Registered: ${item.name} (contract ${item.version}; targets ${item.targets.join(', ') || '(none)'}; ${item.declared ? 'declared' : 'not declared'}; revision ${item.revisionPinned ? 'pinned' : 'NOT pinned'})`,
|
|
137
137
|
` mounts: ${item.mounts.join(', ') || '(none)'}`, ` policy routes: ${item.policyRoutes.join(', ') || '(none)'}`, ` credential headers: ${item.credentialHeaders.join(', ') || '(none)'}`,
|
|
138
|
+
` hooks: ${item.hooks.length ? item.hooks.map(hook => `${String((hook ).name)} (${String((hook ).kind)})`).join(', ') : '(none)'}`,
|
|
138
139
|
` configuration schema: ${JSON.stringify(item.schema)}`, ` policy schema: ${item.policySchema ? JSON.stringify(item.policySchema) : '(none)'}`);
|
|
139
140
|
lines.push(report.note);
|
|
140
141
|
return lines.join('\n') + '\n';
|
package/dist/extensions.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import Ajv from 'ajv/dist/2020.js';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
2
4
|
import { assert, HttpError } from './errors.js';
|
|
3
|
-
import { loadDocument } from './config.js';
|
|
5
|
+
import { functionFile, loadDocument } from './config.js';
|
|
4
6
|
import { prepareFunctionSnapshot } from './policy.js';
|
|
5
7
|
import { validateHeaderName, validateHeaderValue } from './header-validation.js';
|
|
6
8
|
|
|
@@ -52,9 +54,76 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
|
|
|
52
54
|
* vary on credentials. Everything else under the mount stays no-store.
|
|
53
55
|
*/
|
|
54
56
|
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
/** Machine-readable contract for one project hook an extension exposes. */
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
/** Shared schema for project hook references. Omission means trusted execution. */
|
|
70
|
+
export const extensionHookReferenceSchema={
|
|
71
|
+
oneOf:[
|
|
72
|
+
{type:'string',minLength:1,maxLength:1024},
|
|
73
|
+
{type:'object',additionalProperties:false,required:['source'],properties:{
|
|
74
|
+
source:{type:'string',minLength:1,maxLength:1024},
|
|
75
|
+
export:{type:'string',pattern:'^[A-Za-z_][A-Za-z0-9_]*$'},
|
|
76
|
+
sandbox:{type:'boolean'},
|
|
77
|
+
sandboxReason:{type:'string',minLength:1,maxLength:512},
|
|
78
|
+
}},
|
|
79
|
+
],
|
|
80
|
+
} ;
|
|
81
|
+
/** Builds the strict `config.hooks` schema from an extension's declared hook names. */
|
|
82
|
+
export function extensionHooksSchema(contracts ) {
|
|
83
|
+
return {type:'object',additionalProperties:false,properties:Object.fromEntries(contracts.map(contract=>[contract.name,extensionHookReferenceSchema]))};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Loads project hooks once per activation. Project hooks are trusted first-party
|
|
87
|
+
* code by default, matching function/middleware routes. Sandboxed arbitrary-value
|
|
88
|
+
* hooks are not part of contract v1 and are refused rather than run trusted.
|
|
89
|
+
*/
|
|
90
|
+
export async function loadExtensionHooks (config ,contracts ,context ) {
|
|
91
|
+
const known=new Map(contracts.map(contract=>[contract.name,contract]));
|
|
92
|
+
const hooks =Object.create(null) ;
|
|
93
|
+
if(config===undefined)return hooks ;
|
|
94
|
+
assert(config&&typeof config==='object'&&!Array.isArray(config),'Extension hooks must be an object');
|
|
95
|
+
const epoch=randomUUID();
|
|
96
|
+
for(const [name,raw] of Object.entries(config)){
|
|
97
|
+
const contract=known.get(name);assert(contract,`Unknown extension hook: ${name}`);
|
|
98
|
+
assert(typeof raw==='string'||raw&&typeof raw==='object'&&!Array.isArray(raw),`Invalid extension hook: ${name}`);
|
|
99
|
+
const reference =typeof raw==='string'?{source:raw}:raw ;
|
|
100
|
+
if(reference.sandbox===true)throw new Error(`hook ${name}: sandbox: true is not supported for extension hooks; hooks run trusted by default`);
|
|
101
|
+
const exportName=reference.export??'default';
|
|
102
|
+
let modulePath ;
|
|
103
|
+
try{modulePath=await functionFile(context.root,reference.source);}
|
|
104
|
+
catch(error){throw new Error(`hook ${name}: failed to load module "${reference.source}"`,{cause:error});}
|
|
105
|
+
let module ;
|
|
106
|
+
try{module=await import(pathToFileURL(modulePath).href+'?urlcode-extension-hook-epoch='+epoch) ;}
|
|
107
|
+
catch(error){throw new Error(`hook ${name}: failed to load module "${reference.source}"`,{cause:error});}
|
|
108
|
+
const fn=module[exportName];
|
|
109
|
+
if(typeof fn!=='function')throw new Error(`hook ${name}: export "${exportName}" in "${reference.source}" is not a function`);
|
|
110
|
+
const ajv=new Ajv.default({strict:false,allErrors:false});
|
|
111
|
+
const validateInput=ajv.compile(contract.inputSchema);
|
|
112
|
+
const validateOutput=contract.outputSchema?ajv.compile(contract.outputSchema):undefined;
|
|
113
|
+
hooks[name]=(input ) =>{
|
|
114
|
+
assert(validateInput(input),`Invalid extension hook input: ${name}`);
|
|
115
|
+
const validate=(output ) =>{if(validateOutput)assert(validateOutput(output),`Invalid extension hook output: ${name}`);return output;};
|
|
116
|
+
const output=(fn )(input);
|
|
117
|
+
return output instanceof Promise?output.then(validate):validate(output);
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return hooks ;
|
|
121
|
+
}
|
|
55
122
|
|
|
56
123
|
|
|
57
124
|
|
|
125
|
+
|
|
126
|
+
|
|
58
127
|
|
|
59
128
|
|
|
60
129
|
|
|
@@ -105,6 +174,7 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
|
|
|
105
174
|
|
|
106
175
|
|
|
107
176
|
const namePattern=/^[a-z][a-z0-9-]{0,63}$/;
|
|
177
|
+
const hookNamePattern=/^[a-z][A-Za-z0-9]{0,63}$/;
|
|
108
178
|
const cacheHeaders=new Set(['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control']);
|
|
109
179
|
const segmentPattern=/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
|
|
110
180
|
export const immutableCacheControl='public, max-age=31536000, immutable';
|
|
@@ -153,6 +223,13 @@ export function prepareExtensions(document ,routes
|
|
|
153
223
|
assert(registration.version==='1'&&typeof registration.activate==='function','Invalid extension version or activation hook');
|
|
154
224
|
assert(Array.isArray(registration.targets)&®istration.targets.every(target=>['node','aws','vercel'].includes(target)),'Extension targets must be node, aws or vercel');
|
|
155
225
|
assert(typeof registration.projectSha256==='string'&&/^[a-f0-9]{64}$/.test(registration.projectSha256),'Extension requires an explicit operator revision pin');
|
|
226
|
+
const hookNames=new Set ();
|
|
227
|
+
for(const hook of registration.hooks??[]){
|
|
228
|
+
assert(hook&&typeof hook==='object'&&hookNamePattern.test(hook.name)&&!hookNames.has(hook.name),'Invalid extension hook contract');
|
|
229
|
+
assert(['filter','action'].includes(hook.kind)&&typeof hook.description==='string'&&hook.description.length>=1&&hook.description.length<=512,'Invalid extension hook contract');
|
|
230
|
+
assert(hook.inputSchema&&typeof hook.inputSchema==='object'&&(!hook.outputSchema||typeof hook.outputSchema==='object'),'Invalid extension hook contract');
|
|
231
|
+
hookNames.add(hook.name);
|
|
232
|
+
}
|
|
156
233
|
provided.set(registration.name,registration);
|
|
157
234
|
}
|
|
158
235
|
const declarations=document.extensions??{};
|
package/dist/mcp.js
CHANGED
|
@@ -78,7 +78,7 @@ export async function serveMcp(options ) {
|
|
|
78
78
|
if(message.method==='initialize') {
|
|
79
79
|
if(initialized){await error(id,-32600,'Already initialized');return;}
|
|
80
80
|
if(typeof params.protocolVersion!=='string'||!object(params.capabilities)||!object(params.clientInfo)||typeof params.clientInfo.name!=='string'||typeof params.clientInfo.version!=='string'){await error(id,-32602,'Invalid initialize params');return;}
|
|
81
|
-
initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.
|
|
81
|
+
initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.2'}}});return;
|
|
82
82
|
}
|
|
83
83
|
if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
|
|
84
84
|
if(!ready){await error(id,-32002,'Initialize first');return;}
|
package/dist/policy.js
CHANGED
|
@@ -32,6 +32,22 @@ export async function prepareFunctionSnapshot(loaded )
|
|
|
32
32
|
if (route.sandbox) { for (const definition of [...middleware, ...(fn ? [fn] : [])]) sandboxed.push({pattern,function:definition}); }
|
|
33
33
|
else trusted.push({middleware, function: fn});
|
|
34
34
|
}
|
|
35
|
+
// Extension hooks are a core primitive even though their names and payloads
|
|
36
|
+
// belong to each extension. Include every declared entry module in the
|
|
37
|
+
// reviewed project revision, so editing trusted hook code invalidates the
|
|
38
|
+
// operator's extension pin just like editing a trusted route function.
|
|
39
|
+
for(const [extension,declaration] of Object.entries(loaded.document.extensions??{})){
|
|
40
|
+
const hooks=declaration.config.hooks;
|
|
41
|
+
if(hooks===undefined)continue;
|
|
42
|
+
assert(hooks&&typeof hooks==='object'&&!Array.isArray(hooks),`Extension ${extension} hooks must be an object`);
|
|
43
|
+
for(const [name,raw] of Object.entries(hooks )){
|
|
44
|
+
assert(typeof raw==='string'||raw&&typeof raw==='object'&&!Array.isArray(raw),`Invalid extension hook: ${extension}.${name}`);
|
|
45
|
+
const reference=typeof raw==='string'?{source:raw}:raw ;
|
|
46
|
+
assert(typeof reference.source==='string',`Invalid extension hook: ${extension}.${name}`);
|
|
47
|
+
assert(reference.export===undefined||typeof reference.export==='string',`Invalid extension hook: ${extension}.${name}`);
|
|
48
|
+
trusted.push({function:await resolveOne({source:reference.source,...(reference.export===undefined?{}:{export:reference.export })})});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
35
51
|
const collected = await collectFunctionSources(sandboxed,loaded.root);
|
|
36
52
|
const trustedSources = await collectTrustedSources(trusted,loaded.root);
|
|
37
53
|
// The hash operator grants pin to still covers trusted routes' own source, so
|
package/dist/readiness.js
CHANGED
|
@@ -37,6 +37,9 @@ import { runCompliance } from './compliance.js';
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
|
|
@@ -188,7 +191,8 @@ export async function auditProject(app , {expectRoutes,log=()=>{},c
|
|
|
188
191
|
const advisories=plan.inventory.flatMap(route=>(route.advisories??[]).map(message=>({route:route.path,message})));
|
|
189
192
|
// The per-route capability table: which policies apply and whether this
|
|
190
193
|
// host enforces, compiles or delegates each one. Refusals never get here.
|
|
191
|
-
|
|
194
|
+
const notReadyReasons=[...(counts.active>0?[]:['no-active-routes']),...(countMatches?[]:['route-count-mismatch']),...(failed?['failed-checks']:[]),...(uncovered.length?['uncovered-route-methods']:[])];
|
|
195
|
+
return {elapsedMs:performance.now()-began,ready:!notReadyReasons.length,notReadyReasons,counts,expectedRoutes:expectRoutes ?? null,countMatches,checks:cases.length,passed,failed,coveredRouteMethods:covered.size,unassertedCases,uncovered,policies:plan.policies ?? {},compliance:compliance?await runCompliance(app,compliance):null,advisories};
|
|
192
196
|
}
|
|
193
197
|
export async function benchmarkProject(app ,{requests=1000,concurrency=2,maxP95Ms,seconds=30,warmup=0,target} ={}) {
|
|
194
198
|
assert(Number.isInteger(requests)&&requests>=1&&requests<=100000,'Requests must be 1–100000');
|
package/dist/tooling.js
CHANGED
|
@@ -68,7 +68,7 @@ export async function previewImport(options ) {return importR
|
|
|
68
68
|
export async function previewExport(project ,format ,acceptProviderDifferences=false) {const loaded=await loadDocument(project);const {includes:_includes,...document}=loaded.document;return exportRoutes({format,document:{...document,routes:loaded.routes},acceptProviderDifferences});}
|
|
69
69
|
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
/** Reports registered extension contracts against the project's declarations. Never activates an extension. */
|
|
@@ -84,6 +84,7 @@ export async function describeExtensions(project ,registrations
|
|
|
84
84
|
name:String(registration.name),version:String(registration.version),targets:Array.isArray(registration.targets)?registration.targets.map(String):[],
|
|
85
85
|
credentialHeaders:Array.isArray(registration.credentialHeaders)?registration.credentialHeaders.map(String):[],
|
|
86
86
|
schema:structuredClone(registration.schema??{}),policySchema:registration.policySchema?structuredClone(registration.policySchema):null,
|
|
87
|
+
hooks:structuredClone(registration.hooks??[]) ,
|
|
87
88
|
declared:Object.hasOwn(loaded.document.extensions??{},registration.name),revisionPinned:registration.projectSha256===projectSha256,
|
|
88
89
|
mounts:mountsOf(registration.name),policyRoutes:policyRoutesOf(registration.name),
|
|
89
90
|
}));
|
|
@@ -66,6 +66,62 @@ export interface ExtensionInstance {
|
|
|
66
66
|
export interface ExtensionImmutableAssets {
|
|
67
67
|
prefix: string;
|
|
68
68
|
}
|
|
69
|
+
export interface ExtensionHookReference {
|
|
70
|
+
source: string;
|
|
71
|
+
export?: string;
|
|
72
|
+
sandbox?: boolean;
|
|
73
|
+
sandboxReason?: string;
|
|
74
|
+
}
|
|
75
|
+
export type ExtensionHookConfig = string | ExtensionHookReference;
|
|
76
|
+
export type ExtensionHookKind = 'filter' | 'action';
|
|
77
|
+
/** Machine-readable contract for one project hook an extension exposes. */
|
|
78
|
+
export interface ExtensionHookContract {
|
|
79
|
+
name: string;
|
|
80
|
+
kind: ExtensionHookKind;
|
|
81
|
+
description: string;
|
|
82
|
+
inputSchema: object;
|
|
83
|
+
outputSchema?: object;
|
|
84
|
+
}
|
|
85
|
+
export type LoadedExtensionHooks<T extends string = string> = Partial<Record<T, (input: unknown) => unknown>>;
|
|
86
|
+
/** Shared schema for project hook references. Omission means trusted execution. */
|
|
87
|
+
export declare const extensionHookReferenceSchema: {
|
|
88
|
+
readonly oneOf: readonly [{
|
|
89
|
+
readonly type: "string";
|
|
90
|
+
readonly minLength: 1;
|
|
91
|
+
readonly maxLength: 1024;
|
|
92
|
+
}, {
|
|
93
|
+
readonly type: "object";
|
|
94
|
+
readonly additionalProperties: false;
|
|
95
|
+
readonly required: readonly ["source"];
|
|
96
|
+
readonly properties: {
|
|
97
|
+
readonly source: {
|
|
98
|
+
readonly type: "string";
|
|
99
|
+
readonly minLength: 1;
|
|
100
|
+
readonly maxLength: 1024;
|
|
101
|
+
};
|
|
102
|
+
readonly export: {
|
|
103
|
+
readonly type: "string";
|
|
104
|
+
readonly pattern: "^[A-Za-z_][A-Za-z0-9_]*$";
|
|
105
|
+
};
|
|
106
|
+
readonly sandbox: {
|
|
107
|
+
readonly type: "boolean";
|
|
108
|
+
};
|
|
109
|
+
readonly sandboxReason: {
|
|
110
|
+
readonly type: "string";
|
|
111
|
+
readonly minLength: 1;
|
|
112
|
+
readonly maxLength: 512;
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
}];
|
|
116
|
+
};
|
|
117
|
+
/** Builds the strict `config.hooks` schema from an extension's declared hook names. */
|
|
118
|
+
export declare function extensionHooksSchema(contracts: readonly ExtensionHookContract[]): object;
|
|
119
|
+
/**
|
|
120
|
+
* Loads project hooks once per activation. Project hooks are trusted first-party
|
|
121
|
+
* code by default, matching function/middleware routes. Sandboxed arbitrary-value
|
|
122
|
+
* hooks are not part of contract v1 and are refused rather than run trusted.
|
|
123
|
+
*/
|
|
124
|
+
export declare function loadExtensionHooks<T extends string>(config: Readonly<Record<string, unknown>> | undefined, contracts: readonly ExtensionHookContract[], context: Pick<ExtensionActivation, 'root'>): Promise<LoadedExtensionHooks<T>>;
|
|
69
125
|
export interface RuntimeExtension {
|
|
70
126
|
name: string;
|
|
71
127
|
version: '1';
|
|
@@ -75,6 +131,8 @@ export interface RuntimeExtension {
|
|
|
75
131
|
policySchema?: object;
|
|
76
132
|
credentialHeaders?: string[];
|
|
77
133
|
immutableAssets?: ExtensionImmutableAssets;
|
|
134
|
+
/** Project customization points, exposed by CLI/MCP for authors and agents. */
|
|
135
|
+
hooks?: readonly ExtensionHookContract[];
|
|
78
136
|
/**
|
|
79
137
|
* Reviewed, operator-declared cache sensitivity for `policies.extensions.<name>`
|
|
80
138
|
* routes (never for an `extension:` mount, which is always treated as
|
|
@@ -57,6 +57,9 @@ export interface AuditOptions {
|
|
|
57
57
|
export interface AuditReport {
|
|
58
58
|
elapsedMs: number;
|
|
59
59
|
ready: boolean;
|
|
60
|
+
/** Empty when `ready`; otherwise one stable code per failed condition:
|
|
61
|
+
* `no-active-routes`, `route-count-mismatch`, `failed-checks`, `uncovered-route-methods`. */
|
|
62
|
+
notReadyReasons: string[];
|
|
60
63
|
counts: {
|
|
61
64
|
configured: number;
|
|
62
65
|
active: number;
|
package/dist/types/tooling.d.ts
CHANGED
package/docs/AI-AUTHORING.md
CHANGED
|
@@ -273,9 +273,10 @@ either `true` or `false`) or `sandbox: true` is enough to silence it.
|
|
|
273
273
|
|
|
274
274
|
The same judgment call applies to a project-level lifecycle hook an
|
|
275
275
|
extension invokes (`onSignUp`, `beforeRegister` and the like) — it is
|
|
276
|
-
first-party project code with the same trusted-by-default rule
|
|
277
|
-
`
|
|
278
|
-
|
|
276
|
+
first-party project code with the same trusted-by-default rule as a
|
|
277
|
+
`function`/`middleware` route. Extension hook contract v1 is trusted-only;
|
|
278
|
+
`sandbox: true` is rejected rather than silently ignored. See
|
|
279
|
+
[EXTENSIONS.md](EXTENSIONS.md#project-level-lifecycle-hooks).
|
|
279
280
|
|
|
280
281
|
Guest TypeScript needs `build-typescript --project SOURCE --out NEW_DIRECTORY`
|
|
281
282
|
before serving. Only the emitted `.js`/`.mjs` executes, in QuickJS for a
|
|
@@ -294,7 +295,7 @@ origin grants pinned to the project revision. These are self-hosted features;
|
|
|
294
295
|
providers refuse them. Signals are bounded best effort with drops, no retries
|
|
295
296
|
or persistence. Never turn a user request into an implicit network grant.
|
|
296
297
|
|
|
297
|
-
Before using a feature, ask `urlcode capabilities <name>` for its constraints, grants and target support and `urlcode schema <path>` for only that YAML fragment (MCP: `get_capability`, `get_schema`), instead of guessing.
|
|
298
|
+
Before using a feature, ask `urlcode capabilities <name>` for its constraints, grants and target support and `urlcode schema <path>` for only that YAML fragment (MCP: `get_capability`, `get_schema`), instead of guessing. For an installed extension, use `urlcode extensions --host-file ... --json` or MCP `get_extensions`; its hook contracts include the accepted names, purpose and input/output schemas. Prefer extension configuration and UI copy/templates/theme/CSS, then a declared project hook, and only then a new extension or fork.
|
|
298
299
|
The [tooling SDK and stdio MCP](TOOLING.md) inspect, validate, explain and preview
|
|
299
300
|
without guest execution, environment reads or writes. Run `urlcode explain /route`
|
|
300
301
|
to check effective methods, policies and cache outcome, and `urlcode manifest`
|
package/docs/COMPOSING-A-SITE.md
CHANGED
|
@@ -200,15 +200,24 @@ extensions:
|
|
|
200
200
|
onSignUp: ./hooks/on-signup.mjs
|
|
201
201
|
```
|
|
202
202
|
|
|
203
|
-
`hooks`
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
`hooks` remains each package's own config, but core supplies the reference
|
|
204
|
+
schema, trusted loader and machine-readable hook contract. `get_extensions`
|
|
205
|
+
reports those contracts, so an agent can discover accepted names, purpose and
|
|
206
|
+
input/output shapes without guessing from prose.
|
|
206
207
|
|
|
207
208
|
Hooks are first-party project code and run **trusted and in-process**, the
|
|
208
209
|
same default `function` and `middleware` routes have
|
|
209
|
-
([FUNCTION-SECURITY.md](FUNCTION-SECURITY.md)).
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
([FUNCTION-SECURITY.md](FUNCTION-SECURITY.md)). Extension hook contract v1 is
|
|
211
|
+
trusted-only; `sandbox: true` is rejected loudly at activation.
|
|
212
|
+
|
|
213
|
+
### `@jimhoyd/urlcode-ui`
|
|
214
|
+
|
|
215
|
+
| Hook | Input | Returns | Called |
|
|
216
|
+
|---|---|---|---|
|
|
217
|
+
| `transformView` | `{template, view}` | the view object to render | Synchronously before each public `ui.kit.render()` or `ui.kit.page()` call. Use it only when theme, copy, templates and CSS cannot express the change. |
|
|
218
|
+
|
|
219
|
+
For example, `transformView: ./hooks/ui-view.mjs` can add project-computed
|
|
220
|
+
navigation or labels to an auth/admin view without editing either package.
|
|
212
221
|
|
|
213
222
|
### `@jimhoyd/urlcode-auth`
|
|
214
223
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Container image promotion (design, issue #233)
|
|
2
|
+
|
|
3
|
+
Status: design plus an inert helper. Nothing described under "Proposed" is
|
|
4
|
+
implemented, and no workflow was changed. This does not authorize enabling
|
|
5
|
+
`PUBLISH_CONTAINER`, publishing, or changing any existing tag. The point is
|
|
6
|
+
supply-chain integrity (tested bytes are shipped bytes), not speed: the sampled
|
|
7
|
+
CI image build took about 12 seconds, so build caching is deliberately out of scope.
|
|
8
|
+
|
|
9
|
+
## Current behavior (read from the workflows at 67a6996)
|
|
10
|
+
|
|
11
|
+
- `ci.yml` job `container` (a required check) runs `docker build -t urlcode:test .`
|
|
12
|
+
and smoke-tests it. That image is discarded; no digest is recorded.
|
|
13
|
+
- `candidate.yml` builds and signs the npm archives, SBOM and manifests. It builds no
|
|
14
|
+
image. `manifest.json` and the exact-file-set check in `validateCandidate` know nothing
|
|
15
|
+
about images.
|
|
16
|
+
- `release.yml`, only when `vars.PUBLISH_CONTAINER == 'true'`, runs a fresh
|
|
17
|
+
`docker build` of the tagged commit with `revision`/`version` labels and pushes
|
|
18
|
+
`:VERSION` and the channel tag.
|
|
19
|
+
- On retry, if `:VERSION` already exists it is accepted after checking only the
|
|
20
|
+
`revision` label. A label is asserted by whoever built the image, so a differently
|
|
21
|
+
built image with the same label passes. Bytes are not compared.
|
|
22
|
+
- Channel monotonicity is enforced by reading the `version` label of the existing
|
|
23
|
+
channel tag (semver `gte`). That guard is sound and should be kept.
|
|
24
|
+
- A rebuild is not byte-reproducible (it runs `npm ci`, embeds timestamps), so the image
|
|
25
|
+
the release publishes is never the image CI tested, even for the same commit.
|
|
26
|
+
|
|
27
|
+
Not verified: live GHCR contents, package visibility/permissions, whether
|
|
28
|
+
`PUBLISH_CONTAINER` is set, and whether any image was ever pushed.
|
|
29
|
+
|
|
30
|
+
## Proposed path (not implemented)
|
|
31
|
+
|
|
32
|
+
1. Candidate build. In `candidate.yml`, build the image once from the exact commit
|
|
33
|
+
(`docker buildx build --output type=oci,dest=candidate/image.oci.tar` with the
|
|
34
|
+
revision/version labels), run the same three smoke tests as `ci.yml` against that
|
|
35
|
+
archive, and record `image: { digest, sourceCommit, version }` in `manifest.json`.
|
|
36
|
+
Add `image.oci.tar` to the attested subjects so the existing `attestation verify` and
|
|
37
|
+
the manifest SHA-256 pinned in the annotated tag cover the image as well.
|
|
38
|
+
2. Manifest schema. Add the archive to the expected file set in `validateCandidate`
|
|
39
|
+
and validate the record with `candidateImage()` from `scripts/release-image.ts`.
|
|
40
|
+
Because the tag pins the manifest hash, the digest is immutable once tagged.
|
|
41
|
+
3. Promotion. `release.yml` verifies the bundle (already done by `restore`), then copies
|
|
42
|
+
the archive to the registry preserving the manifest digest (`skopeo copy` or
|
|
43
|
+
`crane push` of the OCI layout; not `docker load` then `docker push`, which can
|
|
44
|
+
change digests under some storage drivers) and pushes `:VERSION` and the channel tag
|
|
45
|
+
as pointers to that digest. It never runs `docker build`.
|
|
46
|
+
4. Retry identity. If `:VERSION` exists, resolve its digest with
|
|
47
|
+
`docker buildx imagetools inspect` and require `assertPromotedImage()` to pass. A
|
|
48
|
+
rebuilt image with matching labels but different bytes is refused. Channel tags keep
|
|
49
|
+
the existing `assertChannel` check via `assertImageChannel()`.
|
|
50
|
+
5. Attestation. Optionally attest the image digest with `actions/attest` using
|
|
51
|
+
`push-to-registry`; this needs `packages: write` and its GHCR behavior is unverified.
|
|
52
|
+
6. Self-hosting. The `Dockerfile` and `docker build` remain the documented way to build
|
|
53
|
+
from source. CI's `container` check stays as is to preserve the required check name.
|
|
54
|
+
|
|
55
|
+
## What the inert helper proves
|
|
56
|
+
|
|
57
|
+
`scripts/release-image.ts` and `test/release-image.test.ts` are pure functions and unit
|
|
58
|
+
tests. They pin the invariant (promoted digest equals tested digest; labels match;
|
|
59
|
+
channel never regresses; a same-label different-bytes image is rejected). No workflow
|
|
60
|
+
imports them, so the release path is unchanged.
|
|
61
|
+
|
|
62
|
+
## Could not establish
|
|
63
|
+
|
|
64
|
+
- Whether GHCR preserves the OCI digest through `skopeo`/`crane` from the runner, and
|
|
65
|
+
whether the org's package settings permit it.
|
|
66
|
+
- Behavior of image attestation and OIDC for GHCR.
|
|
67
|
+
- Whether a candidate-built OCI archive fits comfortably within the artifact size and
|
|
68
|
+
90-day retention model; recovery from GitHub release assets would need the archive
|
|
69
|
+
attached there too.
|
|
70
|
+
- Any real run: none was dispatched.
|
|
71
|
+
|
|
72
|
+
A first implementation should be a separate reviewed PR that lands the candidate-side
|
|
73
|
+
build behind an operator-controlled input, and leaves the publisher change until one
|
|
74
|
+
candidate has been produced and inspected.
|