@mnstry/atelier 0.2.0-alpha.14 → 0.2.0-alpha.16
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/CHANGELOG.md +54 -0
- package/README.md +80 -47
- package/SECURITY.md +1 -1
- package/TRADEMARKS.md +2 -2
- package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
- package/contracts/atelier-graph-view.v1.schema.json +1085 -0
- package/contracts/public-api-baseline.json +298 -19
- package/docs/README.md +90 -0
- package/docs/blocks/claims.md +2 -2
- package/docs/blocks/conformance-admission.md +1 -1
- package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
- package/docs/guides/co-authoring.md +113 -0
- package/docs/{distributions.md → guides/distributions.md} +13 -8
- package/docs/guides/extensions.md +189 -0
- package/docs/guides/graph-refresh.md +152 -0
- package/docs/guides/graph-workspace.md +76 -0
- package/docs/{ingestion.md → guides/ingestion.md} +13 -12
- package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
- package/docs/{learning.md → guides/learning.md} +12 -5
- package/docs/{obsidian.md → guides/obsidian.md} +134 -47
- package/docs/guides/ontology-guide.md +111 -0
- package/docs/{personal-workspace.md → guides/personal-workspace.md} +7 -4
- package/docs/{research-starter.md → guides/research-starter.md} +1 -1
- package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
- package/docs/{upgrade.md → guides/upgrade.md} +40 -9
- package/docs/knowledge-health/assessment/capabilities.md +6 -15
- package/docs/knowledge-health/facilitator.md +15 -4
- package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
- package/docs/knowledge-health/quickstart.md +97 -75
- package/docs/knowledge-health/readme.md +13 -5
- package/docs/maintainers/describing-the-atelier.md +173 -0
- package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -5
- package/docs/{announcements.md → reference/announcements.md} +1 -1
- package/docs/{architecture.md → reference/architecture.md} +31 -5
- package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
- package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
- package/docs/{coordination.md → reference/coordination.md} +1 -1
- package/docs/{decisions.md → reference/decisions.md} +3 -3
- package/docs/{design.md → reference/design.md} +1 -1
- package/docs/reference/edge-policy.md +151 -0
- package/docs/reference/graph-view-structured.md +119 -0
- package/docs/reference/graph-view.md +139 -0
- package/docs/reference/graph-workspace.md +37 -0
- package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
- package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
- package/docs/{local-services.md → reference/local-services.md} +1 -1
- package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +6 -4
- package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +5 -66
- package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
- package/docs/reference/portability.md +92 -0
- package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
- package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
- package/docs/{project-options.md → reference/project-options.md} +17 -0
- package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +27 -4
- package/docs/{review-portability.md → reference/review-portability.md} +4 -0
- package/docs/{template-profile.md → reference/template-profile.md} +1 -1
- package/docs/{trackables.md → reference/trackables.md} +2 -2
- package/docs/start/independent-start.md +89 -0
- package/docs/{install.md → start/install.md} +6 -6
- package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
- package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
- package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
- package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
- package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
- package/fixtures/graph-view/synthetic-view.v1.json +1 -0
- package/fixtures/knowledge-health/workshop/README.md +1 -1
- package/fixtures/templates/local-library.v1.json +3 -1
- package/package.json +21 -8
- package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/claude/atelier-local-service/SKILL.md +1 -1
- package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
- package/skills/claude/mnstry-readiness/SKILL.md +2 -2
- package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/codex/atelier-local-service/SKILL.md +1 -1
- package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
- package/skills/codex/mnstry-readiness/SKILL.md +2 -2
- package/src/architecture/catalog.mjs +2 -2
- package/src/attestation/sign.mjs +1 -1
- package/src/boundary/content-rules.mjs +5 -4
- package/src/boundary/policy.mjs +60 -12
- package/src/cli/run.mjs +24 -8
- package/src/commands/distribution.mjs +1 -1
- package/src/commands/extension.mjs +27 -0
- package/src/commands/ingest.mjs +1 -1
- package/src/commands/learn.mjs +1 -1
- package/src/commands/obsidian.mjs +28 -16
- package/src/commands/review.mjs +3 -0
- package/src/edge-policy/contract.mjs +69 -0
- package/src/edge-policy/policy.mjs +142 -0
- package/src/edge-policy/source-citation-adapter.mjs +43 -0
- package/src/extension-packs/loader.mjs +4 -3
- package/src/extensions/admission.mjs +83 -0
- package/src/extensions/client.d.mts +23 -0
- package/src/extensions/client.mjs +99 -0
- package/src/extensions/contracts.d.mts +29 -0
- package/src/extensions/contracts.mjs +163 -0
- package/src/extensions/convex.d.mts +24 -0
- package/src/extensions/convex.mjs +189 -0
- package/src/extensions/delegation.mjs +54 -0
- package/src/extensions/drizzle.d.mts +11 -0
- package/src/extensions/drizzle.mjs +33 -0
- package/src/extensions/postgres.d.mts +7 -0
- package/src/extensions/postgres.mjs +66 -0
- package/src/extensions/prisma.d.mts +13 -0
- package/src/extensions/prisma.mjs +63 -0
- package/src/extensions/project.mjs +432 -0
- package/src/extensions/scaffold-convex.mjs +130 -0
- package/src/extensions/scaffold-sql.mjs +144 -0
- package/src/extensions/server.d.mts +20 -0
- package/src/extensions/server.mjs +102 -0
- package/src/extensions/sql-scope.mjs +22 -0
- package/src/graph/knowledge-graph.mjs +1 -1
- package/src/graph/repo-contribution.mjs +405 -0
- package/src/graph/repo-contribution.types.mjs +32 -0
- package/src/graph-access/edge-decision-adapter.mjs +41 -0
- package/src/graph-access/edge-record-adapter.mjs +73 -0
- package/src/graph-access/index.mjs +253 -0
- package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
- package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
- package/src/graph-access/source-adapter.mjs +81 -0
- package/src/graph-refresh/compare.mjs +110 -0
- package/src/graph-refresh/controller.mjs +324 -0
- package/src/graph-refresh/index.mjs +3 -0
- package/src/graph-refresh/local.mjs +78 -0
- package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
- package/src/graph-view/babylon/index.mjs +2 -0
- package/src/graph-view/babylon/navigator.mjs +312 -0
- package/src/graph-view/babylon/provenance.mjs +36 -0
- package/src/graph-view/babylon/scene-controller.mjs +131 -0
- package/src/graph-view/babylon/semantic-picking.mjs +35 -0
- package/src/graph-view/babylon/web-host.mjs +188 -0
- package/src/graph-view/cli.mjs +3 -0
- package/src/graph-view/command.mjs +34 -0
- package/src/graph-view/contracts.mjs +79 -0
- package/src/graph-view/errors.mjs +5 -0
- package/src/graph-view/index.mjs +4 -0
- package/src/graph-view/intents.mjs +30 -0
- package/src/graph-view/query.mjs +213 -0
- package/src/graph-view/refs.mjs +2 -0
- package/src/graph-view/structured/browser.mjs +262 -0
- package/src/graph-view/structured/styles.mjs +8 -0
- package/src/graph-workspace/authoring.mjs +185 -0
- package/src/graph-workspace/browser/dom.mjs +23 -0
- package/src/graph-workspace/browser/index.mjs +6 -0
- package/src/graph-workspace/browser/map.mjs +374 -0
- package/src/graph-workspace/browser/review.mjs +55 -0
- package/src/graph-workspace/browser/styles.mjs +11 -0
- package/src/graph-workspace/browser/workbench.mjs +351 -0
- package/src/graph-workspace/browser/workspace.mjs +433 -0
- package/src/graph-workspace/disclosure.mjs +39 -0
- package/src/graph-workspace/geometry.mjs +78 -0
- package/src/graph-workspace/index.mjs +11 -0
- package/src/graph-workspace/labels.mjs +50 -0
- package/src/graph-workspace/model.mjs +109 -0
- package/src/graph-workspace/recipes.mjs +100 -0
- package/src/graph-workspace/scale.mjs +98 -0
- package/src/graph-workspace/theme.mjs +156 -0
- package/src/knowledge/ontology-guide.mjs +152 -0
- package/src/knowledge-health/proof/installed-check.mjs +2 -2
- package/src/knowledge-health/rule-identity.mjs +7 -7
- package/src/knowledge-health/workshop.mjs +22 -2
- package/src/personal-workspace/selection.mjs +17 -2
- package/src/project/config.mjs +1 -1
- package/src/project/providers/github.mjs +24 -0
- package/src/project/repo-identity.mjs +3 -22
- package/src/projection/obsidian/contracts.mjs +1 -1
- package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +1 -1
- package/src/projection/obsidian/materialize/redaction.mjs +1 -1
- package/src/projection/obsidian/personal-workspace.mjs +24 -28
- package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
- package/src/projection/obsidian/publication/publisher.mjs +25 -4
- package/src/projection/project.mjs +18 -14
- package/src/readiness/readiness.mjs +20 -11
- package/src/readiness-protocols/bundled-pack.mjs +14 -766
- package/src/readiness-protocols/bundled-selection.mjs +32 -0
- package/src/readiness-protocols/evidence.mjs +2 -1
- package/src/readiness-protocols/packs/mnstry.mjs +766 -0
- package/src/readiness-protocols/runtime.mjs +43 -6
- package/src/runtime/obsidian/engine.mjs +99 -26
- package/src/runtime/obsidian/lifecycle.mjs +3 -3
- package/src/runtime/obsidian/login-item.mjs +2 -2
- package/src/runtime/obsidian/machine-settings.mjs +52 -8
- package/src/runtime/obsidian/opening.mjs +2 -2
- package/src/runtime/obsidian/personal-selection.mjs +88 -26
- package/src/runtime/obsidian/pipeline.mjs +75 -7
- package/src/runtime/obsidian/private-lock.mjs +11 -2
- package/src/runtime/obsidian/selection-continuity.mjs +124 -0
- package/src/runtime/obsidian/service-main.mjs +15 -9
- package/src/runtime/obsidian/service.mjs +14 -0
- package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
- package/src/runtime/obsidian/view-permission.mjs +2 -1
- package/src/upgrade/upgrade.mjs +23 -4
- package/templates/distribution-workspace/README.md +2 -2
- package/templates/external-project-workspace/atelier.project.json +3 -0
- package/templates/external-project-workspace/pack-lifecycle.json +3 -1
- package/templates/knowledge-workspace/README.md +2 -2
- package/docs/integration-and-review-plan.md +0 -409
- package/docs/integration-contract-decisions.md +0 -44
- package/docs/knowledge-health/devday/facilitator.md +0 -16
- package/docs/knowledge-health/devday/public-default.md +0 -107
- package/docs/knowledge-health/devday/quickstart.md +0 -18
- package/docs/knowledge-health/devday/support.md +0 -22
- package/docs/knowledge-health/exercise.md +0 -17
- package/docs/knowledge-health/support.md +0 -34
- package/docs/presentation-review-disposition.md +0 -47
- package/docs/progressive-ingestion-acceptance.md +0 -89
- package/docs/progressive-ingestion.md +0 -235
- /package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +0 -0
- /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
- /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
- /package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +0 -0
- /package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +0 -0
- /package/docs/{local-review.md → guides/local-review.md} +0 -0
- /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
- /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
- /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
- /package/docs/{attestation.md → reference/attestation.md} +0 -0
- /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
- /package/docs/{continuity.md → reference/continuity.md} +0 -0
- /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
- /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
- /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
- /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
- /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
- /package/docs/{local-state.md → reference/local-state.md} +0 -0
- /package/docs/{ontology.md → reference/ontology.md} +0 -0
- /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
- /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
- /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
- /package/docs/{semantic-operation.md → reference/semantic-operation.md} +0 -0
- /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
package/src/commands/ingest.mjs
CHANGED
|
@@ -9,7 +9,7 @@ ignored .atelier-local/ state. Plans bind explicit sources, scope and budgets.
|
|
|
9
9
|
Run processes bounded local text, CSV and JSON. Status retains every selected
|
|
10
10
|
source's disposition. Query returns source-bound lexical evidence with coverage;
|
|
11
11
|
no operation accepts semantic claims, calls a provider or enrolls raw files into
|
|
12
|
-
the graph. Output is private JSON. See docs/ingestion.md.`
|
|
12
|
+
the graph. Output is private JSON. See docs/guides/ingestion.md.`
|
|
13
13
|
|
|
14
14
|
try {
|
|
15
15
|
const command = process.argv[2]
|
package/src/commands/learn.mjs
CHANGED
|
@@ -17,7 +17,7 @@ lesson digest. Activate makes approved content available to a named harness;
|
|
|
17
17
|
it does not install files or grant tool permissions. Withdraw stops future use.
|
|
18
18
|
List, context, render, graph, export and plan print private JSON to stdout.
|
|
19
19
|
Export is an explicit private archive, never a send or publication operation.
|
|
20
|
-
See docs/learning.md for complete examples.`
|
|
20
|
+
See docs/guides/learning.md for complete examples.`
|
|
21
21
|
|
|
22
22
|
function exactObject(value, allowed, required) {
|
|
23
23
|
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
@@ -17,7 +17,7 @@ import { BUILT_IN_OPERATIONS, UNAVAILABLE_APPLY_OPERATION, createObsidianRegistr
|
|
|
17
17
|
import { LIFECYCLE_PRIMITIVES, readServiceStatusDocument, requestServiceTick, runtimeRelease, serviceStatus, startService, stopService } from '../runtime/obsidian/lifecycle.mjs'
|
|
18
18
|
import {
|
|
19
19
|
DECISIONS, ONLY_YOU_AUDIENCES, authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, installApplyPolicy, localPointerPath, protectedRoots,
|
|
20
|
-
readInstalledApplyPolicy, readMachineSettings, revokeApplyPolicy, withDecision, writeMachineSettings,
|
|
20
|
+
readInstalledApplyPolicy, readLocalPointer, readMachineSettings, revokeApplyPolicy, withDecision, writeMachineSettings,
|
|
21
21
|
} from '../runtime/obsidian/machine-settings.mjs'
|
|
22
22
|
import {
|
|
23
23
|
currentLoginItemPlan, installLoginItem, loginItemDataRoot, loginItemLabel, loginItemStarter, loginItemStatus, planLoginItem, projectNameOf, readLoginItemRecord, realNodePath,
|
|
@@ -29,7 +29,7 @@ import { pluginPresenceOf, turnPluginOnNext, withPluginFilesPending, withPluginR
|
|
|
29
29
|
import { planViewAdd, viewFromRequest, writeViewPlan } from '../runtime/obsidian/project-views.mjs'
|
|
30
30
|
import { askGoAhead, createQuestioner } from '../runtime/obsidian/questions.mjs'
|
|
31
31
|
import { readServiceSettings } from '../runtime/obsidian/service-record.mjs'
|
|
32
|
-
import { resolveServiceWorkspace } from '../runtime/obsidian/service.mjs'
|
|
32
|
+
import { resolveServiceWorkspace, serviceLocationCheck } from '../runtime/obsidian/service.mjs'
|
|
33
33
|
import { STARTUP_PLATFORMS, buildStartupAdapter, startupSearchPath } from '../runtime/obsidian/startup-adapters.mjs'
|
|
34
34
|
import { OBSIDIAN_SETTINGS_FILE, obsidianSandboxedBuild, obsidianUserDataDir, readObsidianSettings } from '../projection/obsidian/publication/vault-list.mjs'
|
|
35
35
|
import { checkVaultParent, projectDisplayName, vaultFolderName } from '../runtime/obsidian/vault-location.mjs'
|
|
@@ -115,7 +115,7 @@ export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.pro
|
|
|
115
115
|
workspace (the personal-workspace module's selection); its views come from the overlay. Nothing here confirms one: while
|
|
116
116
|
none is confirmed and current nothing is published, and status, open and service start refuse with the reason. service
|
|
117
117
|
status and stop, unit --remove and uninstall find the service from the home alone. Who may see its vaults stays this
|
|
118
|
-
machine's audience setting
|
|
118
|
+
machine's audience setting: owner only when undecided; an explicit clear stays empty.
|
|
119
119
|
Reaching the installed app needs --adapter=${PRODUCTION_ADAPTER} once for a workspace; it is remembered after that.
|
|
120
120
|
Obsidian's own settings file (obsidian.json) is written only while no Obsidian runs: open adds the view's vault to its
|
|
121
121
|
list, turns its command line on (cli: true), and creates the file for an Obsidian that never started; every write is
|
|
@@ -383,7 +383,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
383
383
|
const personalHome = flags['personal-home'] === undefined ? null : path.resolve(cwd, flags['personal-home'])
|
|
384
384
|
if (personalHome !== null && configFlag !== undefined) refuse('usage', '--personal-home is given instead of --project, not with it')
|
|
385
385
|
const selection = personalHome === null ? null : await import('../runtime/obsidian/personal-selection.mjs')
|
|
386
|
-
const loadProject = options.loadProject ?? (selection !== null ? () => selection.loadSelectedProject({ personalHome }) : () => resolveProjectConfig({ argv: configFlag === undefined ? [] : [`--project=${path.resolve(cwd, configFlag)}`], cwd, env, writeLocalState: false }))
|
|
386
|
+
const loadProject = options.loadProject ?? (selection !== null ? () => selection.loadSelectedProject({ personalHome, dataRoot, env, platform }) : () => resolveProjectConfig({ argv: configFlag === undefined ? [] : [`--project=${path.resolve(cwd, configFlag)}`], cwd, env, writeLocalState: false }))
|
|
387
387
|
const locateProject = selection === null ? null : () => selection.locatePersonalHome({ personalHome })
|
|
388
388
|
const registry = createObsidianRegistry({ reservedOperations: BUILT_IN_OPERATIONS, contributions: contributions ?? await loadContributions(contributionsDirectory === undefined ? {} : { directory: contributionsDirectory }) })
|
|
389
389
|
const applyAvailable = registry.extensions.applyOperation() !== UNAVAILABLE_APPLY_OPERATION
|
|
@@ -470,8 +470,8 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
470
470
|
const pluginOf = async (scopeId) => pluginPresenceOf((await readServiceStatusDocument(lifecycle, lifecycleRules)).document, scopeId)
|
|
471
471
|
const pluginLine = (plugin) => `${plugin.present ? `; plugin present (Obsidian ${plugin.appVersion})` : plugin.reason === 'turned-off-in-this-vault' ? '; plugin turned off in this vault' : `; plugin not present (${plugin.reason})`}${plugin.files === 'waits-for-app' ? '; plugin files wait for the app' : ''}`
|
|
472
472
|
|
|
473
|
-
// A
|
|
474
|
-
//
|
|
473
|
+
// A personal binding takes its views from the overlay and keeps its audience owner only (or explicitly empty).
|
|
474
|
+
// Operations outside those machine decisions refuse before anything is resolved or written.
|
|
475
475
|
const refuseOnPersonalBinding = async (project, operation) => (await import('../projection/obsidian/personal-workspace.mjs')).refuseOnPersonalBinding(project, operation)
|
|
476
476
|
const configured = () => {
|
|
477
477
|
const project = loadProject()
|
|
@@ -482,7 +482,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
482
482
|
// Private state of this workspace, created on demand for an operation that writes it.
|
|
483
483
|
const writable = () => {
|
|
484
484
|
const { project, enablement } = configured()
|
|
485
|
-
ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }) })
|
|
485
|
+
ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }), validate: serviceLocationCheck({ project, dataRoot, env, platform }) })
|
|
486
486
|
const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create: true })
|
|
487
487
|
return { project, enablement, workspace, repositoryRoots: protectedRoots(project), now: isoTime(clock) }
|
|
488
488
|
}
|
|
@@ -632,7 +632,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
632
632
|
const removed = await removeLoginItem({ loadProject: locateProject ?? loadProject, dataRoot, env, platform, manager, clock })
|
|
633
633
|
const failed = removed.removed !== true && removed.reason !== undefined && removed.reason !== 'workspace-not-prepared'
|
|
634
634
|
if (failed) return { exit: EXIT.notSuccess, document: { loginItem: removed }, human: [`login item not removed: ${removed.reason}${removed.message ? ` (${removed.message})` : ''}`, `Next: ${NEXT[removed.reason] ?? 'see `atelier obsidian status`'}`] }
|
|
635
|
-
const remembered = removed.reason === 'workspace-not-prepared' ? false : rememberLoginItem('off')
|
|
635
|
+
const remembered = removed.reason === 'workspace-not-prepared' ? false : rememberLoginItem('off', { locate: true })
|
|
636
636
|
let service = null
|
|
637
637
|
if (removed.removed === true) {
|
|
638
638
|
let entry = null
|
|
@@ -657,9 +657,18 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
657
657
|
// The person's answer to "start at login?", remembered for this workspace as the `loginItem` decision of its machine
|
|
658
658
|
// settings, given on the command line. By whom: the actor named, else a person at a terminal by the account's name,
|
|
659
659
|
// else nobody known. Answers whether it wrote; an answer already remembered is not written again.
|
|
660
|
-
const rememberLoginItem = (choice) => {
|
|
661
|
-
|
|
662
|
-
|
|
660
|
+
const rememberLoginItem = (choice, { locate = false } = {}) => {
|
|
661
|
+
let workspace, repositoryRoots, now
|
|
662
|
+
if (locate && locateProject !== null) {
|
|
663
|
+
const project = locateProject()
|
|
664
|
+
workspace = resolveServiceWorkspace({ project, dataRoot, env, platform })
|
|
665
|
+
if (!workspace?.workspaceRoot) return false
|
|
666
|
+
repositoryRoots = protectedRoots(project)
|
|
667
|
+
now = isoTime(clock)
|
|
668
|
+
} else {
|
|
669
|
+
if (readable().enablement.reason === 'not-configured') return false
|
|
670
|
+
;({ workspace, repositoryRoots, now } = writable())
|
|
671
|
+
}
|
|
663
672
|
const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
|
|
664
673
|
if (current.decisions.loginItem?.choice === choice) return false
|
|
665
674
|
const decidedBy = flags['consent-actor'] ?? (interactive ? accountActor(account()) : null)
|
|
@@ -693,7 +702,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
693
702
|
}
|
|
694
703
|
const vaults = [...new Set([...underDataRoot, ...allocated])].sort()
|
|
695
704
|
const userDataDir = obsidianUserDataDir({ platform, env })
|
|
696
|
-
return { vaults, privateState: workspace?.workspaceRoot ?? null, projectFile: project.configPath ?? null, pointer: project.
|
|
705
|
+
return { vaults, privateState: workspace?.workspaceRoot ?? null, projectFile: project.configPath ?? null, pointer: typeof project.configDir === 'string' && readLocalPointer(project) !== null ? localPointerPath(project) : null, obsidianList: userDataDir === null ? null : path.join(userDataDir, OBSIDIAN_SETTINGS_FILE) }
|
|
697
706
|
}
|
|
698
707
|
|
|
699
708
|
// Where a view's vault is, or will be: { path, origin }. A view the next tick will place is shown where it would go,
|
|
@@ -901,7 +910,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
901
910
|
// `me` stands for "only you". Named beside other audiences, it adds its own to theirs, and the list is the person's own choice.
|
|
902
911
|
const audienceAllow = [...new Set(named.flatMap((item) => (item === 'me' ? ONLY_YOU_AUDIENCES : [item])))]
|
|
903
912
|
const choice = named.length === 1 && named[0] === 'me' ? 'only-you' : 'custom'
|
|
904
|
-
await refuseOnPersonalBinding(loadProject(), `audience ${sub}`)
|
|
913
|
+
await refuseOnPersonalBinding(loadProject(), choice === 'only-you' ? 'audience set me' : `audience ${sub}`)
|
|
905
914
|
const { workspace, repositoryRoots, now } = writable()
|
|
906
915
|
const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
|
|
907
916
|
// A vault that is only yours shows the notes that carry no classification too; any other list withholds them.
|
|
@@ -1090,8 +1099,10 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
1090
1099
|
// again, then the proven service stops. Everything a person made or may want back stays, and is named.
|
|
1091
1100
|
async uninstall() {
|
|
1092
1101
|
const project = (locateProject ?? loadProject)()
|
|
1093
|
-
|
|
1094
|
-
|
|
1102
|
+
// Only an established absence (no pointer, or no private state yet) is unprepared. A location or identity refusal
|
|
1103
|
+
// stops here, before any login-item or service action, so uninstall never reports success over it.
|
|
1104
|
+
const found = resolveServiceWorkspace({ project, dataRoot, env, platform })
|
|
1105
|
+
const workspace = found?.workspaceRoot ? found : null
|
|
1095
1106
|
const record = workspace === null ? null : readLoginItemRecord(workspace)
|
|
1096
1107
|
// What is kept is read before anything is stopped or removed, so nothing it finds can stop the answer after that.
|
|
1097
1108
|
const kept = keptLocations(project, workspace)
|
|
@@ -1099,7 +1110,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
1099
1110
|
const service = workspace === null ? { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' } : await stopService(lifecycle, lifecycleRules)
|
|
1100
1111
|
const itemGone = item.removed === true || item.reason === 'not-installed' || item.reason === 'workspace-not-prepared'
|
|
1101
1112
|
const serviceGone = service.stopped === true || service.state === 'stopped'
|
|
1102
|
-
const remembered = workspace !== null && itemGone ? rememberLoginItem('off') : false
|
|
1113
|
+
const remembered = workspace !== null && itemGone ? rememberLoginItem('off', { locate: true }) : false
|
|
1103
1114
|
return {
|
|
1104
1115
|
exit: itemGone && serviceGone ? EXIT.ok : EXIT.notSuccess,
|
|
1105
1116
|
document: { loginItem: item, service, kept, rememberedNow: { loginItem: remembered ? 'off' : null } },
|
|
@@ -1111,6 +1122,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
1111
1122
|
...kept.vaults.map((vault) => ` vault ${vault}`),
|
|
1112
1123
|
...(kept.privateState === null ? [] : [` private state ${kept.privateState}`]),
|
|
1113
1124
|
` project file ${kept.projectFile ?? '(none)'}; its Obsidian settings stay`,
|
|
1125
|
+
...(kept.pointer === null ? [] : [` workspace pointer ${kept.pointer}`]),
|
|
1114
1126
|
...(kept.obsidianList === null ? [] : [` Obsidian's list ${kept.obsidianList}; the vaults stay in it`]),
|
|
1115
1127
|
],
|
|
1116
1128
|
}
|
package/src/commands/review.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
createProtocolRegistry,
|
|
8
8
|
} from '../extension-packs/loader.mjs'
|
|
9
9
|
import { runProtocol } from '../readiness-protocols/runtime.mjs'
|
|
10
|
+
import { bundledReadinessEnabled, requireSelectedReadinessProtocol } from '../readiness-protocols/bundled-selection.mjs'
|
|
10
11
|
import { recordReviewEvidence } from '../readiness-protocols/evidence.mjs'
|
|
11
12
|
import { createReviewStore } from '../collaboration/review-store.mjs'
|
|
12
13
|
import {
|
|
@@ -73,7 +74,9 @@ let result
|
|
|
73
74
|
if (args._[0] === 'run') {
|
|
74
75
|
const registry = createProtocolRegistry({
|
|
75
76
|
packs: loadExtensionPacks(project).packs,
|
|
77
|
+
includeBundled: bundledReadinessEnabled(project),
|
|
76
78
|
})
|
|
79
|
+
requireSelectedReadinessProtocol(project, registry, args._[1])
|
|
77
80
|
const answers = args.answers ? readJson(path.resolve(args.answers)) : {}
|
|
78
81
|
const { run } = runProtocol(project, args._[1], {
|
|
79
82
|
answers,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { canonicalize } from '../attestation/jcs.mjs'
|
|
3
|
+
|
|
4
|
+
// Experimental adapter shapes, not an ontology or identity authority.
|
|
5
|
+
export const EDGE_RECORD_SCHEMA = 'atelier-edge-record@experimental-v1'
|
|
6
|
+
export const EDGE_DECISION_SCHEMA = 'atelier-edge-decision@experimental-v1'
|
|
7
|
+
export const EDGE_FAMILIES = Object.freeze(['hyperlink', 'source-declaration', 'reviewed-assertion'])
|
|
8
|
+
export const EDGE_ACTIONS = Object.freeze(['propose', 'approve', 'accept', 'reject', 'suspend', 'withdraw', 'supersede'])
|
|
9
|
+
const digestPattern = /^sha256:[a-f0-9]{64}$/
|
|
10
|
+
const text = (v, max = 256) => typeof v === 'string' && v.length > 0 && v.trim() === v && v.length <= max && !/[\u0000-\u001f\u007f]/.test(v)
|
|
11
|
+
const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
12
|
+
const closed = (v, keys) => object(v) && Object.keys(v).length === keys.length && keys.every((k) => Object.hasOwn(v, k))
|
|
13
|
+
const relativePath = (v) => text(v, 2048) && !v.includes('\\') && !v.startsWith('/') && !/^[A-Za-z]:/.test(v) && v.split('/').every((p) => p && p !== '.' && p !== '..')
|
|
14
|
+
export const validNodeRef = (v) => closed(v, ['repoId', 'localId']) && text(v.repoId) && text(v.localId)
|
|
15
|
+
export const bytesDigest = (v) => `sha256:${crypto.createHash('sha256').update(v).digest('hex')}`
|
|
16
|
+
export const edgeDigest = (v) => bytesDigest(canonicalize(v))
|
|
17
|
+
export function immutable(value) {
|
|
18
|
+
if (value && typeof value === 'object') {
|
|
19
|
+
for (const child of Object.values(value)) immutable(child)
|
|
20
|
+
Object.freeze(value)
|
|
21
|
+
}
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function validSourceRef(v) {
|
|
26
|
+
return closed(v, ['repoId', 'path', 'revision', 'fileDigest', 'locator', 'citedDigest']) &&
|
|
27
|
+
text(v.repoId) && relativePath(v.path) && text(v.revision) && digestPattern.test(v.fileDigest) && digestPattern.test(v.citedDigest) &&
|
|
28
|
+
closed(v.locator, ['byteStart', 'byteEnd']) && Number.isSafeInteger(v.locator.byteStart) && v.locator.byteStart >= 0 &&
|
|
29
|
+
Number.isSafeInteger(v.locator.byteEnd) && v.locator.byteEnd > v.locator.byteStart
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateEdgeRecord(v) {
|
|
33
|
+
try {
|
|
34
|
+
if (!closed(v, ['schema', 'family', 'subject', 'predicate', 'object', 'home', 'origin', 'supports', 'policyDigest', 'proposalRef', 'sourceChange', 'qualifications'])) return false
|
|
35
|
+
if (v.schema !== EDGE_RECORD_SCHEMA || !EDGE_FAMILIES.includes(v.family) || !validNodeRef(v.subject) || !validNodeRef(v.object) || !text(v.predicate)) return false
|
|
36
|
+
if (!closed(v.home, ['repoId', 'path', 'readership']) || !text(v.home.repoId) || !relativePath(v.home.path) || !text(v.home.readership)) return false
|
|
37
|
+
if (!validSourceRef(v.origin) || !Array.isArray(v.supports) || v.supports.length > 256 ||
|
|
38
|
+
v.supports.some((s) => !closed(s, ['id', 'source']) || !text(s.id) || !validSourceRef(s.source)) || new Set(v.supports.map((s) => s.id)).size !== v.supports.length) return false
|
|
39
|
+
if (!digestPattern.test(v.policyDigest) || (v.proposalRef !== null && !text(v.proposalRef, 2048)) ||
|
|
40
|
+
!Array.isArray(v.qualifications) || v.qualifications.length > 64 || v.qualifications.some((q) => !text(q, 2048))) return false
|
|
41
|
+
if (v.family === 'hyperlink' && (v.predicate !== 'links-to' || v.sourceChange !== null)) return false
|
|
42
|
+
if (v.sourceChange !== null && (!closed(v.sourceChange, ['repoId', 'path', 'baseRevision', 'diffDigest', 'reviewRef']) ||
|
|
43
|
+
!text(v.sourceChange.repoId) || !relativePath(v.sourceChange.path) || !text(v.sourceChange.baseRevision) || !digestPattern.test(v.sourceChange.diffDigest) || !text(v.sourceChange.reviewRef, 2048))) return false
|
|
44
|
+
edgeDigest(v)
|
|
45
|
+
return true
|
|
46
|
+
} catch { return false }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateEdgeDecision(v) {
|
|
50
|
+
try {
|
|
51
|
+
if (!closed(v, ['schema', 'recordDigest', 'sequence', 'previousDigest', 'action', 'actorId', 'at', 'policyDigest', 'reason', 'roles', 'signature'])) return false
|
|
52
|
+
if (v.schema !== EDGE_DECISION_SCHEMA || !digestPattern.test(v.recordDigest) || !Number.isSafeInteger(v.sequence) || v.sequence < 1 ||
|
|
53
|
+
(v.previousDigest !== null && !digestPattern.test(v.previousDigest)) || !EDGE_ACTIONS.includes(v.action) || !text(v.actorId) ||
|
|
54
|
+
!text(v.at) || !Number.isFinite(Date.parse(v.at)) || new Date(v.at).toISOString() !== v.at || !digestPattern.test(v.policyDigest) || !text(v.reason, 2048)) return false
|
|
55
|
+
if (!Array.isArray(v.roles) || v.roles.length > 3 || v.roles.some((r) => !closed(r, ['repoId', 'role']) || !text(r.repoId) || !['home-owner', 'endpoint-steward'].includes(r.role)) ||
|
|
56
|
+
new Set(v.roles.map((r) => canonicalize(r))).size !== v.roles.length) return false
|
|
57
|
+
if (v.action !== 'approve' && v.roles.length !== 0) return false
|
|
58
|
+
if (v.action === 'approve' && v.roles.length === 0) return false
|
|
59
|
+
if (!closed(v.signature, ['algorithm', 'keyId', 'value']) || !['ed25519', 'es256'].includes(v.signature.algorithm) || !text(v.signature.keyId) || !text(v.signature.value, 512)) return false
|
|
60
|
+
edgeDigest(v)
|
|
61
|
+
return true
|
|
62
|
+
} catch { return false }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function edgeIdentity(record) {
|
|
66
|
+
if (!validateEdgeRecord(record)) throw new TypeError('invalid edge record')
|
|
67
|
+
const recordDigest = edgeDigest(record)
|
|
68
|
+
return Object.freeze({ edgeId: `edge:${recordDigest.slice(7)}`, recordDigest })
|
|
69
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { verifyDocument } from '../attestation/sign.mjs'
|
|
2
|
+
import { bytesDigest, edgeDigest, edgeIdentity, immutable, validateEdgeDecision, validateEdgeRecord } from './contract.mjs'
|
|
3
|
+
|
|
4
|
+
const roleKey = (role) => `${role.role}\0${role.repoId}`
|
|
5
|
+
const same = (a, b) => edgeDigest(a) === edgeDigest(b)
|
|
6
|
+
const fail = (state, reason) => ({ state, eligible: false, reason })
|
|
7
|
+
const decisions = new WeakSet()
|
|
8
|
+
const seal = (value) => { const result = immutable(value); decisions.add(result); return result }
|
|
9
|
+
function demand(value, reason) { if (!value) throw new Error(reason) }
|
|
10
|
+
|
|
11
|
+
function validPolicy(p) {
|
|
12
|
+
return p && Object.keys(p).sort().join(',') === 'allowProposerReview,allowSharedSteward,version' &&
|
|
13
|
+
typeof p.version === 'string' && p.version.length > 0 && typeof p.allowProposerReview === 'boolean' && typeof p.allowSharedSteward === 'boolean'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Content-bound occurrence identity only; this does not establish access,
|
|
17
|
+
// source currency or eligibility. Keep caller-owned records untouched.
|
|
18
|
+
export function edgeEvidenceOccurrences(record) {
|
|
19
|
+
const { edgeId } = edgeIdentity(record)
|
|
20
|
+
const occurrence = (id, source) => ({ id: `evidence:${edgeDigest({ edgeId, occurrenceId: id, source }).slice(7)}`, occurrenceId: id, source: structuredClone(source) })
|
|
21
|
+
return immutable([occurrence('origin', record.origin), ...record.supports.map((s) => occurrence(`support:${s.id}`, s.source))])
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Host functions are trusted integration ports. No record-supplied identity,
|
|
25
|
+
// eligible flag, audience or local asserted review substitutes for these ports.
|
|
26
|
+
// The host must hold one coherent current snapshot across an evaluation.
|
|
27
|
+
export async function evaluateEdge(input, host) {
|
|
28
|
+
let record, events
|
|
29
|
+
try {
|
|
30
|
+
record = structuredClone(input.record)
|
|
31
|
+
events = structuredClone(input.events)
|
|
32
|
+
if (!validateEdgeRecord(record) || !Array.isArray(events) || events.length > 1024 || events.some((e) => !validateEdgeDecision(e))) return immutable(fail('suspended', 'invalid-input'))
|
|
33
|
+
immutable(record); immutable(events)
|
|
34
|
+
const identity = edgeIdentity(record)
|
|
35
|
+
const policy = immutable(structuredClone(host.policy))
|
|
36
|
+
demand(validPolicy(policy), 'policy-unavailable')
|
|
37
|
+
const policyDigest = edgeDigest(policy)
|
|
38
|
+
demand(typeof host.bindingId === 'string' && host.bindingId.length > 0, 'snapshot-binding-unavailable')
|
|
39
|
+
const base = { ...identity, family: record.family, policyDigest, verification: { checkedAt: host.now, bindingId: host.bindingId }, reviewRefs: [], triple: { subject: record.subject, predicate: record.predicate, object: record.object },
|
|
40
|
+
supportRefs: edgeEvidenceOccurrences(record), qualifications: record.qualifications,
|
|
41
|
+
sourceChange: record.sourceChange, history: events }
|
|
42
|
+
const result = (value) => seal({ ...base, ...value })
|
|
43
|
+
if (record.policyDigest !== policyDigest) return result(fail('suspended', 'policy-changed'))
|
|
44
|
+
const now = Date.parse(host.now)
|
|
45
|
+
demand(Number.isFinite(now), 'clock-unavailable')
|
|
46
|
+
const head = await host.readRecordHead(identity)
|
|
47
|
+
if (!head || head.recordDigest !== identity.recordDigest || head.eventCount !== events.length || head.eventDigest !== (events.length ? edgeDigest(events.at(-1)) : null)) return result(fail('suspended', 'history-head-mismatch'))
|
|
48
|
+
const origin = await host.verifyOrigin({ record, ...identity, policyDigest })
|
|
49
|
+
if (!origin || origin.recordDigest !== identity.recordDigest || origin.bindingId !== host.bindingId || origin.matches !== true) return result(fail('suspended', 'origin-mismatch'))
|
|
50
|
+
// Safe placement considers the home's entire intended readership, not only
|
|
51
|
+
// this reader. The host checks endpoints, all evidence and record metadata.
|
|
52
|
+
const placement = await host.checkSourceHome({ record, ...identity, policyDigest })
|
|
53
|
+
if (!placement || placement.recordDigest !== identity.recordDigest || placement.policyDigest !== policyDigest || placement.bindingId !== host.bindingId || placement.allowed !== true) return result(fail('suspended', 'unsafe-source-home'))
|
|
54
|
+
const endpoints = await Promise.all([record.subject, record.object].map((ref) => host.resolveEndpoint(ref)))
|
|
55
|
+
if (endpoints.some((e, i) => !e || !same(e.ref, [record.subject, record.object][i]) || e.current !== true || typeof e.stewardId !== 'string' || !e.stewardId)) return result(fail('suspended', 'endpoint-unavailable'))
|
|
56
|
+
const home = await host.resolveHomeOwner(record.home.repoId)
|
|
57
|
+
if (!home || typeof home.actorId !== 'string' || !home.actorId) return result(fail('suspended', 'home-owner-unavailable'))
|
|
58
|
+
for (const { source } of base.supportRefs) {
|
|
59
|
+
const observed = await host.readSource(source)
|
|
60
|
+
if (!observed || observed.revision !== source.revision || !(observed.bytes instanceof Uint8Array)) return result(fail('stale', 'source-changed'))
|
|
61
|
+
const bytes = Buffer.from(observed.bytes)
|
|
62
|
+
if (bytesDigest(bytes) !== source.fileDigest || source.locator.byteEnd > bytes.length ||
|
|
63
|
+
bytesDigest(bytes.subarray(source.locator.byteStart, source.locator.byteEnd)) !== source.citedDigest) return result(fail('stale', 'citation-changed'))
|
|
64
|
+
}
|
|
65
|
+
const semantic = record.family !== 'hyperlink'
|
|
66
|
+
const crossRepo = record.subject.repoId !== record.object.repoId
|
|
67
|
+
const governed = semantic && (crossRepo || record.family === 'reviewed-assertion' || events.length > 0)
|
|
68
|
+
if (!semantic) return result(events.length ? fail('suspended', 'navigation-has-decisions') : { state: 'active', eligible: true, assurance: 'navigation' })
|
|
69
|
+
if (!governed) return result({ state: 'active', eligible: true, assurance: 'source-declaration' })
|
|
70
|
+
if (!record.proposalRef || !record.sourceChange || record.sourceChange.repoId !== record.home.repoId || record.sourceChange.path !== record.home.path) return result(fail('suspended', 'source-owner-handoff-missing'))
|
|
71
|
+
// Review roles in this adapter are repository-scoped. Conflicting resource
|
|
72
|
+
// stewards inside one repository cannot be collapsed into one approval.
|
|
73
|
+
if (!crossRepo && endpoints[0].stewardId !== endpoints[1].stewardId) return result(fail('suspended', 'endpoint-steward-conflict'))
|
|
74
|
+
const required = [{ repoId: record.home.repoId, role: 'home-owner', actorId: home.actorId },
|
|
75
|
+
...[...new Map(endpoints.map((e) => [e.ref.repoId, { repoId: e.ref.repoId, role: 'endpoint-steward', actorId: e.stewardId }])).values()]]
|
|
76
|
+
if (crossRepo && endpoints[0].stewardId === endpoints[1].stewardId && !policy.allowSharedSteward) return result(fail('suspended', 'shared-steward-not-delegated'))
|
|
77
|
+
let previous = null, state = 'draft', proposer = null, lastTime = -Infinity
|
|
78
|
+
const approvals = new Map()
|
|
79
|
+
for (const [index, event] of events.entries()) {
|
|
80
|
+
if (event.sequence !== index + 1 || event.previousDigest !== previous || event.recordDigest !== identity.recordDigest || event.policyDigest !== policyDigest || Date.parse(event.at) < lastTime || Date.parse(event.at) > now) return result(fail('suspended', 'decision-chain-invalid'))
|
|
81
|
+
if (['withdrawn', 'rejected', 'superseded', 'suspended'].includes(state)) return result(fail('suspended', 'terminal-history-continued'))
|
|
82
|
+
const binding = await host.resolveActorKey({ actorId: event.actorId, keyId: event.signature.keyId, at: host.now })
|
|
83
|
+
if (!binding || binding.actorId !== event.actorId || binding.revoked !== false || !Number.isFinite(Date.parse(binding.notBefore)) || !Number.isFinite(Date.parse(binding.expiresAt)) ||
|
|
84
|
+
Date.parse(binding.notBefore) > Date.parse(event.at) || Date.parse(binding.expiresAt) <= now || !verifyDocument(event, { publicKey: binding.publicKey }).valid) return result(fail('suspended', 'actor-key-unavailable'))
|
|
85
|
+
const grant = await host.authorizeDecision({ event, record, required, policyDigest })
|
|
86
|
+
if (!grant || grant.eventDigest !== edgeDigest(event) || grant.policyDigest !== policyDigest || grant.bindingId !== host.bindingId || grant.allowed !== true || grant.revoked !== false ||
|
|
87
|
+
!Number.isFinite(Date.parse(grant.notBefore)) || Date.parse(grant.notBefore) > Date.parse(event.at) || !Number.isFinite(Date.parse(grant.expiresAt)) || Date.parse(grant.expiresAt) <= now) return result(fail('suspended', 'decision-grant-unavailable'))
|
|
88
|
+
if (event.action === 'propose') {
|
|
89
|
+
if (state !== 'draft') return result(fail('suspended', 'invalid-transition'))
|
|
90
|
+
proposer = event.actorId; state = 'awaiting-endpoint-review'
|
|
91
|
+
} else if (event.action === 'approve') {
|
|
92
|
+
if (state !== 'awaiting-endpoint-review' || (!policy.allowProposerReview && event.actorId === proposer)) return result(fail('suspended', 'invalid-reviewer'))
|
|
93
|
+
for (const role of event.roles) {
|
|
94
|
+
const match = required.find((r) => roleKey(r) === roleKey(role) && r.actorId === event.actorId)
|
|
95
|
+
if (!match) return result(fail('suspended', 'invalid-reviewer'))
|
|
96
|
+
approvals.set(roleKey(role), edgeDigest(event))
|
|
97
|
+
}
|
|
98
|
+
} else if (event.action === 'accept') {
|
|
99
|
+
if (state !== 'awaiting-endpoint-review' || event.actorId !== home.actorId || required.some((r) => !approvals.has(roleKey(r)))) return result(fail('suspended', 'missing-reviewer'))
|
|
100
|
+
state = 'active'
|
|
101
|
+
} else {
|
|
102
|
+
if (state === 'draft' || ![home.actorId, proposer, ...endpoints.map((e) => e.stewardId)].includes(event.actorId)) return result(fail('suspended', 'invalid-transition'))
|
|
103
|
+
state = { reject: 'rejected', suspend: 'suspended', withdraw: 'withdrawn', supersede: 'superseded' }[event.action]
|
|
104
|
+
}
|
|
105
|
+
previous = edgeDigest(event); lastTime = Date.parse(event.at)
|
|
106
|
+
}
|
|
107
|
+
return result({ state, eligible: state === 'active', assurance: state === 'active' ? 'reviewed-assertion' : 'pending', reviewRefs: [...new Set(approvals.values())].sort() })
|
|
108
|
+
} catch {
|
|
109
|
+
// Port errors must not disclose hidden paths, actor details or source bytes.
|
|
110
|
+
return immutable(fail('suspended', 'verification-unavailable'))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Input results must already be authorized for one recipient and evaluated in
|
|
115
|
+
// the same host snapshot. Preserve each complete assertion and its supports.
|
|
116
|
+
export function groupEligibleEdges(results) {
|
|
117
|
+
const groups = new Map()
|
|
118
|
+
let binding = null
|
|
119
|
+
for (const result of results) {
|
|
120
|
+
if (!decisions.has(result)) throw new Error('unverified edge decision')
|
|
121
|
+
if (!result.eligible || result.state !== 'active') continue
|
|
122
|
+
const current = edgeDigest({ bindingId: result.verification.bindingId, policyDigest: result.policyDigest })
|
|
123
|
+
if (binding !== null && binding !== current) throw new Error('mixed edge decision bindings')
|
|
124
|
+
binding = current
|
|
125
|
+
const key = edgeDigest({ family: result.family, triple: result.triple, qualifications: result.qualifications })
|
|
126
|
+
const group = groups.get(key) ?? { groupId: `edge-group:${key.slice(7)}`, family: result.family, triple: result.triple, assertions: [] }
|
|
127
|
+
if (group.assertions.some((a) => a.edgeId === result.edgeId && edgeDigest(a) !== edgeDigest(result))) throw new Error('conflicting edge decisions')
|
|
128
|
+
if (!group.assertions.some((a) => a.edgeId === result.edgeId)) group.assertions.push(structuredClone(result))
|
|
129
|
+
groups.set(key, group)
|
|
130
|
+
}
|
|
131
|
+
return immutable([...groups.values()].map((g) => ({ ...g, assertions: g.assertions.sort((a, b) => a.edgeId.localeCompare(b.edgeId, 'en')) })).sort((a, b) => a.groupId.localeCompare(b.groupId, 'en')))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Only evaluated decisions may enter this seam; a deserialized eligible flag is
|
|
135
|
+
// never authority. The access host revalidates bindingId/current grants at use.
|
|
136
|
+
export function displayEligibleEdge(result) {
|
|
137
|
+
if (!decisions.has(result) || !result.eligible || result.state !== 'active') return null
|
|
138
|
+
return immutable({ edgeId: result.edgeId, source: result.triple.subject, target: result.triple.object,
|
|
139
|
+
predicate: result.triple.predicate, family: result.family, acceptanceState: result.state, assurance: result.assurance,
|
|
140
|
+
supportRefs: result.supportRefs.map((s) => s.id), sourceRef: result.supportRefs[0].id, reviewRefs: result.reviewRefs,
|
|
141
|
+
qualifications: result.qualifications, recordDigest: result.recordDigest, policyDigest: result.policyDigest, verification: result.verification })
|
|
142
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { canonicalize } from '../attestation/jcs.mjs'
|
|
2
|
+
import { validateRepositoryContribution } from '../graph/repo-contribution.mjs'
|
|
3
|
+
import { validSourceRef } from './contract.mjs'
|
|
4
|
+
|
|
5
|
+
const need = (value) => { if (!value) throw new TypeError('Invalid source citation input') }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert an exact observed occurrence to the policy's raw-byte citation shape.
|
|
9
|
+
* This pure structural binding does not authenticate source bytes, source-home
|
|
10
|
+
* custody, freshness or a declared triple. The policy host must still implement
|
|
11
|
+
* readSource, verifyOrigin and checkSourceHome against its current snapshot.
|
|
12
|
+
* Original provenance, including UTF16 offsets and any pointer/target range,
|
|
13
|
+
* stays unchanged in the contribution. Only its outer raw-byte range is cited.
|
|
14
|
+
* @param {import('../graph/repo-contribution.types.mjs').RepositoryContribution} contribution
|
|
15
|
+
* @param {import('../graph/repo-contribution.types.mjs').SourceRef} provenance
|
|
16
|
+
*/
|
|
17
|
+
export function sourceCitationFromProvenance(contribution, provenance) {
|
|
18
|
+
try {
|
|
19
|
+
need(validateRepositoryContribution(contribution).ok)
|
|
20
|
+
const { snapshot, repository } = contribution
|
|
21
|
+
need(provenance?.repoId === repository.repoId && provenance.revision === snapshot.digest)
|
|
22
|
+
const file = snapshot.files.find((pin) => pin.path === provenance.path)
|
|
23
|
+
need(file && provenance.digest === file.rawDigest)
|
|
24
|
+
const { range, rangeDigest } = provenance.locator ?? {}
|
|
25
|
+
need(range && Number.isSafeInteger(range.byteStart) && range.byteStart >= 0 &&
|
|
26
|
+
Number.isSafeInteger(range.byteEnd) && range.byteEnd > range.byteStart && range.byteEnd <= file.byteLength)
|
|
27
|
+
// A matching file pin alone cannot invent a source occurrence. Accept a
|
|
28
|
+
// detached copy too, while retaining every field of the recorded provenance.
|
|
29
|
+
const observed = canonicalize(provenance)
|
|
30
|
+
need([...contribution.ordinaryLinks, ...contribution.declaredRelations]
|
|
31
|
+
.some((occurrence) => canonicalize(occurrence.provenance) === observed))
|
|
32
|
+
const citation = {
|
|
33
|
+
repoId: provenance.repoId, path: provenance.path, revision: provenance.revision,
|
|
34
|
+
fileDigest: provenance.digest,
|
|
35
|
+
locator: { byteStart: range.byteStart, byteEnd: range.byteEnd },
|
|
36
|
+
citedDigest: rangeDigest,
|
|
37
|
+
}
|
|
38
|
+
need(validSourceRef(citation))
|
|
39
|
+
return citation
|
|
40
|
+
} catch {
|
|
41
|
+
throw new TypeError('Invalid source citation input')
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -468,7 +468,8 @@ export function loadExtensionPacks(project, { report = false, verifyLock = true
|
|
|
468
468
|
// Explicit registry object, no global state. Bundled resolution keeps its
|
|
469
469
|
// existing id | slug | namespaced affordance; pack protocols resolve by full
|
|
470
470
|
// namespaced id only, so a pack can never shadow or squat a bundled slug.
|
|
471
|
-
export function createProtocolRegistry({ packs = [] } = {}) {
|
|
471
|
+
export function createProtocolRegistry({ packs = [], includeBundled = true } = {}) {
|
|
472
|
+
if (typeof includeBundled !== 'boolean') throw new TypeError('includeBundled must be a boolean')
|
|
472
473
|
const packProtocols = new Map()
|
|
473
474
|
for (const pack of packs) {
|
|
474
475
|
for (const record of pack.protocols ?? []) packProtocols.set(record.id, record)
|
|
@@ -476,14 +477,14 @@ export function createProtocolRegistry({ packs = [] } = {}) {
|
|
|
476
477
|
return {
|
|
477
478
|
packs,
|
|
478
479
|
protocolById(id) {
|
|
479
|
-
const bundled = getBundledReadinessProtocol(id)
|
|
480
|
+
const bundled = includeBundled ? getBundledReadinessProtocol(id) : null
|
|
480
481
|
if (bundled) return bundled
|
|
481
482
|
const record = packProtocols.get(id)
|
|
482
483
|
return record ? record.protocol : null
|
|
483
484
|
},
|
|
484
485
|
listProtocols() {
|
|
485
486
|
return [
|
|
486
|
-
...bundledReadinessProtocols,
|
|
487
|
+
...(includeBundled ? bundledReadinessProtocols : []),
|
|
487
488
|
...[...packProtocols.values()].map((record) => record.protocol),
|
|
488
489
|
]
|
|
489
490
|
},
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { canonicalJson, exact, ExtensionError } from './contracts.mjs'
|
|
2
|
+
|
|
3
|
+
function joinBytes(chunks, size) {
|
|
4
|
+
const bytes = new Uint8Array(size)
|
|
5
|
+
let offset = 0
|
|
6
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }
|
|
7
|
+
return bytes
|
|
8
|
+
}
|
|
9
|
+
async function cancelResponseBody(body) {
|
|
10
|
+
if (!body) return
|
|
11
|
+
let timer
|
|
12
|
+
try {
|
|
13
|
+
await Promise.race([
|
|
14
|
+
Promise.resolve().then(() => body.cancel()).catch(() => {}),
|
|
15
|
+
new Promise(resolve => { timer = setTimeout(resolve, 100) }),
|
|
16
|
+
])
|
|
17
|
+
} finally { clearTimeout(timer) }
|
|
18
|
+
}
|
|
19
|
+
export async function readInvocationBody(request) {
|
|
20
|
+
const body = request.body?.getReader()
|
|
21
|
+
if (!body) throw new ExtensionError('invalid-input')
|
|
22
|
+
try {
|
|
23
|
+
let size = 0
|
|
24
|
+
const chunks = []
|
|
25
|
+
for (;;) {
|
|
26
|
+
const { value, done } = await body.read()
|
|
27
|
+
if (done) break
|
|
28
|
+
size += value.byteLength
|
|
29
|
+
if (size > 65536) throw new ExtensionError('invalid-input')
|
|
30
|
+
chunks.push(value)
|
|
31
|
+
}
|
|
32
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(joinBytes(chunks, size)))
|
|
33
|
+
} finally { await body.cancel().catch(() => {}) }
|
|
34
|
+
}
|
|
35
|
+
export function createRootAdmissionConsumer(options) {
|
|
36
|
+
return createAdmissionConsumer(options, options.allowLocalTesting && ['test', 'development'].includes(process.env.NODE_ENV))
|
|
37
|
+
}
|
|
38
|
+
export function createAdmissionConsumer({ origin, fetch: hostFetch = globalThis.fetch }, localTestingAuthorized = false) {
|
|
39
|
+
const target = new URL(origin)
|
|
40
|
+
const local = localTestingAuthorized &&
|
|
41
|
+
target.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(target.hostname)
|
|
42
|
+
if ((target.protocol !== 'https:' && !local) || target.username || target.password || target.pathname !== '/' || target.search || target.hash) throw new ExtensionError('invalid-input')
|
|
43
|
+
const endpoint = new URL('/api/root-runtime/extensions/admissions/consume', target)
|
|
44
|
+
return async (_claims, delegation) => {
|
|
45
|
+
// Convex supports AbortController and action timers, but not every runtime
|
|
46
|
+
// implements AbortSignal.timeout. The deadline includes the response body.
|
|
47
|
+
const controller = new AbortController()
|
|
48
|
+
let timer, response, reader
|
|
49
|
+
const expired = new Promise((_, reject) => {
|
|
50
|
+
timer = setTimeout(() => { controller.abort(); reject(new ExtensionError('unavailable', 503)) }, 5000)
|
|
51
|
+
})
|
|
52
|
+
const consume = async () => {
|
|
53
|
+
response = await hostFetch(endpoint, { method: 'POST', redirect: 'error', signal: controller.signal,
|
|
54
|
+
headers: { 'content-type': 'application/json' }, body: canonicalJson({ delegation }) })
|
|
55
|
+
if (response.status === 401 || response.status === 403) return false
|
|
56
|
+
if (response.status != 200 || response.headers.get('content-type')?.split(';')[0] !== 'application/json') throw new ExtensionError('unavailable', 503)
|
|
57
|
+
reader = response.body?.getReader()
|
|
58
|
+
if (!reader) throw new ExtensionError('unavailable', 503)
|
|
59
|
+
try {
|
|
60
|
+
let size = 0
|
|
61
|
+
const chunks = []
|
|
62
|
+
for (;;) {
|
|
63
|
+
const { value, done } = await reader.read()
|
|
64
|
+
if (done) break
|
|
65
|
+
size += value.byteLength
|
|
66
|
+
if (size > 1024) throw new ExtensionError('unavailable', 503)
|
|
67
|
+
chunks.push(value)
|
|
68
|
+
}
|
|
69
|
+
const body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(joinBytes(chunks, size)))
|
|
70
|
+
exact(body, ['admitted'])
|
|
71
|
+
if (body.admitted !== true) throw new ExtensionError('unavailable', 503)
|
|
72
|
+
return true
|
|
73
|
+
} catch { throw new ExtensionError('unavailable', 503) }
|
|
74
|
+
}
|
|
75
|
+
try { return await Promise.race([consume(), expired]) } finally {
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
// Also close refusal/error responses, whose body reader was never acquired.
|
|
78
|
+
// Abort first; a broken/custom stream cancellation cannot extend transport.
|
|
79
|
+
controller.abort()
|
|
80
|
+
await cancelResponseBody(reader ?? response?.body)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ExtensionReleaseBundle, ExtensionReleaseRegistration, ExtensionManifest, InstallationBinding, Invocation, Json, OperationReceipt } from './contracts.mjs'
|
|
2
|
+
export interface HostTransport {
|
|
3
|
+
getInstallation(id: string): Promise<InstallationBinding>;
|
|
4
|
+
invoke(request: Invocation): Promise<OperationReceipt>;
|
|
5
|
+
lookupOperation(id: string, key: string, action: string): Promise<OperationReceipt | null>;
|
|
6
|
+
recoverOperation(binding: InstallationBinding, key: string, action: string): Promise<OperationReceipt>;
|
|
7
|
+
recordResult(operationId: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export interface AdminHostTransport {
|
|
10
|
+
registerRelease?(bundle: ExtensionReleaseBundle): Promise<ExtensionReleaseRegistration>;
|
|
11
|
+
configure(manifest: ExtensionManifest, connectionRef: string, environment: 'development' | 'staging' | 'production'): Promise<InstallationBinding>;
|
|
12
|
+
transition(binding: InstallationBinding, transition: 'verify' | 'enable' | 'disable' | 'remove'): Promise<InstallationBinding>;
|
|
13
|
+
upgrade(binding: InstallationBinding, manifest: ExtensionManifest): Promise<InstallationBinding>;
|
|
14
|
+
}
|
|
15
|
+
export function createHttpHostTransport(hostFetch: typeof fetch): HostTransport & AdminHostTransport
|
|
16
|
+
export function createExtensionClient(transport: HostTransport): {
|
|
17
|
+
getInstallation(id: string): Promise<InstallationBinding>;
|
|
18
|
+
invoke(binding: InstallationBinding, actionId: string, input: Json, idempotencyKey: string): Promise<OperationReceipt>;
|
|
19
|
+
lookupOperation(id: string, key: string, action: string): Promise<OperationReceipt | null>;
|
|
20
|
+
recoverOperation(binding: InstallationBinding, key: string, action: string): Promise<OperationReceipt>;
|
|
21
|
+
recordResult(operationId: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export function createExtensionAdminClient(transport: AdminHostTransport): Omit<AdminHostTransport, 'configure' | 'registerRelease'> & { registerRelease(bundle: ExtensionReleaseBundle): Promise<ExtensionReleaseRegistration>; configure(manifest: ExtensionManifest, connectionRef: string, environment?: 'development' | 'staging' | 'production'): Promise<InstallationBinding> }
|