@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15
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 +283 -0
- package/README.md +102 -52
- 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/capability-stewardship.md +1 -1
- package/docs/blocks/claims.md +2 -2
- package/docs/blocks/conformance-admission.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
- package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
- 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 +64 -0
- package/docs/{ingestion.md → guides/ingestion.md} +16 -15
- package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
- package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
- package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
- package/docs/{learning.md → guides/learning.md} +12 -5
- package/docs/{obsidian.md → guides/obsidian.md} +370 -23
- package/docs/guides/ontology-guide.md +111 -0
- package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
- 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} +197 -11
- 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 -4
- 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} +43 -4
- package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
- package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
- 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} +50 -4
- package/docs/{review-portability.md → reference/review-portability.md} +4 -0
- package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
- 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} +9 -9
- 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 +22 -8
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- 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 +534 -68
- package/src/boundary/policy.mjs +187 -60
- package/src/cli/run.mjs +26 -9
- 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 +49 -21
- 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/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +20 -2
- 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/constellation.mjs +369 -0
- package/src/graph-workspace/browser/dom.mjs +23 -0
- package/src/graph-workspace/browser/index.mjs +6 -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 +431 -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 +88 -0
- package/src/graph-workspace/scale.mjs +95 -0
- package/src/graph-workspace/theme.mjs +156 -0
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -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 +14 -9
- package/src/knowledge-health/workshop.mjs +22 -2
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +34 -3
- package/src/project/config.mjs +1 -1
- package/src/project/path-match.mjs +2 -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 +88 -6
- package/src/projection/obsidian/materialize/redaction.mjs +1 -1
- package/src/projection/obsidian/personal-workspace.mjs +84 -5
- package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +96 -8
- package/src/projection/project.mjs +21 -16
- 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 +387 -76
- package/src/runtime/obsidian/lifecycle.mjs +20 -15
- package/src/runtime/obsidian/login-item.mjs +6 -3
- package/src/runtime/obsidian/machine-settings.mjs +52 -8
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +4 -3
- package/src/runtime/obsidian/personal-selection.mjs +187 -0
- package/src/runtime/obsidian/pipeline.mjs +115 -6
- package/src/runtime/obsidian/private-lock.mjs +50 -6
- package/src/runtime/obsidian/selection-continuity.mjs +124 -0
- package/src/runtime/obsidian/service-main.mjs +68 -16
- package/src/runtime/obsidian/service.mjs +80 -6
- package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
- package/src/runtime/obsidian/view-permission.mjs +335 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +42 -11
- 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/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
- /package/docs/{guided-upgrades.md → guides/guided-upgrades.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/{vault-service.md → reference/vault-service.md} +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { canonicalJson, exact, ExtensionError, parseInstallationBinding, parseManifest, parseOperationReceipt, parseReleaseBundle, text } from './contracts.mjs'
|
|
2
|
+
|
|
3
|
+
const opaque = value => { if (!/^[A-Za-z0-9_-]{16,128}$/.test(text(value))) throw new ExtensionError('invalid-input'); return value }
|
|
4
|
+
const actionIdValue = value => { if (!/^[a-z0-9][a-z0-9._-]{2,127}$/.test(text(value)) || /^(?:lifecycle|installation|extension)\./.test(value)) throw new ExtensionError('invalid-input'); return value }
|
|
5
|
+
const codes = new Set(['invalid-input', 'unauthorized', 'forbidden', 'stale-installation', 'disabled', 'idempotency-conflict', 'operation-unknown', 'release-conflict', 'unavailable'])
|
|
6
|
+
const registration = (value, releaseDigest) => {
|
|
7
|
+
const result=exact(JSON.parse(canonicalJson(value)),['releaseId','releaseDigest'])
|
|
8
|
+
if(result.releaseDigest!==releaseDigest)throw new ExtensionError('unavailable',503)
|
|
9
|
+
return {releaseId:opaque(result.releaseId),releaseDigest:result.releaseDigest}
|
|
10
|
+
}
|
|
11
|
+
// The caller supplies an authenticated host fetch; destinations and credentials are host-owned.
|
|
12
|
+
export function createHttpHostTransport(hostFetch) {
|
|
13
|
+
if (typeof hostFetch !== 'function') throw new ExtensionError('invalid-input')
|
|
14
|
+
const base = '/api/root-runtime/extensions'
|
|
15
|
+
const segment = value => encodeURIComponent(opaque(value))
|
|
16
|
+
async function send(path, body) {
|
|
17
|
+
const response = await hostFetch(`${base}${path}`, { method: body === undefined ? 'GET' : 'POST',
|
|
18
|
+
credentials: 'same-origin', cache: 'no-store', redirect: 'error',
|
|
19
|
+
...(body === undefined ? {} : { headers: { 'content-type': 'application/json' }, body: canonicalJson(body) }) })
|
|
20
|
+
let value
|
|
21
|
+
const reader = response.body?.getReader()
|
|
22
|
+
if (!reader) throw new ExtensionError('unavailable', 503)
|
|
23
|
+
try {
|
|
24
|
+
const chunks = []; let size = 0
|
|
25
|
+
for (;;) {
|
|
26
|
+
const part = await reader.read()
|
|
27
|
+
if (part.done) break
|
|
28
|
+
size += part.value.byteLength
|
|
29
|
+
if (size > 131072) throw new ExtensionError('unavailable', 503)
|
|
30
|
+
chunks.push(part.value)
|
|
31
|
+
}
|
|
32
|
+
const bytes = new Uint8Array(size); let offset = 0
|
|
33
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }
|
|
34
|
+
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
|
|
35
|
+
} catch { throw new ExtensionError('unavailable', 503) } finally { await reader.cancel().catch(() => {}) }
|
|
36
|
+
if (!response.ok) throw new ExtensionError(codes.has(value?.code) ? value.code : 'unavailable', response.status)
|
|
37
|
+
return value
|
|
38
|
+
}
|
|
39
|
+
const installation = id => `/installations/${segment(id)}`
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
registerRelease: async input => {
|
|
42
|
+
const bundle=parseReleaseBundle(input), expectedReleaseDigest=bundle.manifest.releaseDigest
|
|
43
|
+
return registration(await send('/releases',bundle),expectedReleaseDigest)
|
|
44
|
+
},
|
|
45
|
+
getInstallation: async id => parseInstallationBinding(await send(installation(id))),
|
|
46
|
+
invoke: async request => {
|
|
47
|
+
const { installationId, actionId, ...body } = request
|
|
48
|
+
return parseOperationReceipt(await send(`${installation(installationId)}/actions/${encodeURIComponent(actionIdValue(actionId))}`, body))
|
|
49
|
+
},
|
|
50
|
+
lookupOperation: async (id, idempotencyKey, actionId) => {
|
|
51
|
+
const value = await send(`${installation(id)}/lookup`, { idempotencyKey, actionId })
|
|
52
|
+
return value === null ? null : parseOperationReceipt(value)
|
|
53
|
+
},
|
|
54
|
+
recoverOperation: async ({ installationId, ...binding }, idempotencyKey, actionId) =>
|
|
55
|
+
parseOperationReceipt(await send(`${installation(installationId)}/recover`, { ...binding, idempotencyKey, actionId })),
|
|
56
|
+
recordResult: async operationId => {
|
|
57
|
+
const value = exact(await send(`/operations/${segment(operationId)}/result`, {}), ['recorded'])
|
|
58
|
+
if (value.recorded !== true) throw new ExtensionError('unavailable', 503)
|
|
59
|
+
},
|
|
60
|
+
configure: async (manifest, connectionRef, environment) => parseInstallationBinding(await send('/installations', { manifest, connectionRef, environment })),
|
|
61
|
+
transition: async ({ installationId, ...binding }, transition) => parseInstallationBinding(await send(`${installation(installationId)}/lifecycle`, { ...binding, transition })),
|
|
62
|
+
upgrade: async ({ installationId, ...binding }, manifest) => parseInstallationBinding(await send(`${installation(installationId)}/upgrade`, { ...binding, manifest })),
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
export function createExtensionClient(transport) {
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
getInstallation: async id => parseInstallationBinding(await transport.getInstallation(opaque(id))),
|
|
68
|
+
invoke: async (binding, actionId, input, idempotencyKey) => parseOperationReceipt(await transport.invoke({
|
|
69
|
+
...parseInstallationBinding(binding), actionId: actionIdValue(actionId), idempotencyKey: text(idempotencyKey), input: JSON.parse(canonicalJson(input)),
|
|
70
|
+
})),
|
|
71
|
+
lookupOperation: async (id, key, action) => {
|
|
72
|
+
const result = await transport.lookupOperation(opaque(id), text(key), actionIdValue(action))
|
|
73
|
+
return result === null ? null : parseOperationReceipt(result)
|
|
74
|
+
},
|
|
75
|
+
recoverOperation: async (binding, key, action) => parseOperationReceipt(await transport.recoverOperation(parseInstallationBinding(binding), text(key), actionIdValue(action))),
|
|
76
|
+
recordResult: async id => {
|
|
77
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(text(id))) throw new ExtensionError('invalid-input')
|
|
78
|
+
await transport.recordResult(id)
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
export function createExtensionAdminClient(transport) {
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
registerRelease: async value => {
|
|
85
|
+
if(typeof transport.registerRelease!=='function')throw new ExtensionError('unavailable',503)
|
|
86
|
+
const bundle=parseReleaseBundle(value), expectedReleaseDigest=bundle.manifest.releaseDigest
|
|
87
|
+
return registration(await transport.registerRelease(bundle),expectedReleaseDigest)
|
|
88
|
+
},
|
|
89
|
+
configure: async (manifest, connectionRef, environment = 'development') => {
|
|
90
|
+
if (!['development', 'staging', 'production'].includes(environment)) throw new ExtensionError('invalid-input')
|
|
91
|
+
return parseInstallationBinding(await transport.configure(parseManifest(manifest), text(connectionRef), environment))
|
|
92
|
+
},
|
|
93
|
+
transition: async (binding, transition) => {
|
|
94
|
+
if (!['verify', 'enable', 'disable', 'remove'].includes(transition)) throw new ExtensionError('invalid-input')
|
|
95
|
+
return parseInstallationBinding(await transport.transition(parseInstallationBinding(binding), transition))
|
|
96
|
+
},
|
|
97
|
+
upgrade: async (binding, manifest) => parseInstallationBinding(await transport.upgrade(parseInstallationBinding(binding), parseManifest(manifest))),
|
|
98
|
+
})
|
|
99
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
|
|
2
|
+
export interface InstallationBinding { installationId: string; generation: string; releaseDigest: string }
|
|
3
|
+
export interface Invocation extends InstallationBinding { actionId: string; idempotencyKey: string; input: Json }
|
|
4
|
+
export interface OperationReceipt { operationId: string; state: 'pending' | 'admitted' | 'succeeded' | 'unknown' | 'failed'; result?: Json; resultDigest?: string }
|
|
5
|
+
export interface ReservedExtensionFields { ext?: Record<string, never> }
|
|
6
|
+
export interface ActionManifest extends ReservedExtensionFields { id: string; effect: 'read' | 'write'; scope: 'member' | 'tenant'; inputSchemaRef: string; outputSchemaRef: string; timeoutMs: number }
|
|
7
|
+
export interface ExtensionManifest extends ReservedExtensionFields {
|
|
8
|
+
contractVersion?: string;
|
|
9
|
+
schema: 'mnstry.extension-manifest/v1'; extensionId: string; releaseVersion: string; releaseDigest: string; protocol: 'mnstry.extension/v1';
|
|
10
|
+
compatibility: ReservedExtensionFields & { sdkMajor: 1; rootContracts: string[]; hosts: ('web' | 'native')[] };
|
|
11
|
+
deployment: ReservedExtensionFields & { profile: 'postgres-sql' | 'drizzle-db-first' | 'drizzle-code-first' | 'prisma-7-postgres' | 'prisma-8-postgres-experimental' | 'convex-native'; bundleDigest: string; schemaDigest: string }; actions: ActionManifest[];
|
|
12
|
+
recovery?: ReservedExtensionFields & { lookupActionId: string }; permissions: ReservedExtensionFields & { rootReads: string[]; rootWrites: string[]; egress: string[] };
|
|
13
|
+
surfaces: (ReservedExtensionFields & { id: string; compositionDigest: string; actionIds: string[] })[];
|
|
14
|
+
data: ReservedExtensionFields & { classes: string[]; retention: 'until-user-delete'; exportVersion: 1; schemaVersion: string; readableSchemaVersions: string[] }
|
|
15
|
+
}
|
|
16
|
+
export class ExtensionError extends Error { readonly code: string; readonly status: number; constructor(code: string, status?: number) }
|
|
17
|
+
export function canonicalJson(value: unknown): string
|
|
18
|
+
export function text(value: unknown, limit?: number): string
|
|
19
|
+
export function exact(value: unknown, required: string[], optional?: string[]): Record<string, unknown>
|
|
20
|
+
export function parseManifest(value: unknown): ExtensionManifest
|
|
21
|
+
export function parseInstallationBinding(value: unknown): InstallationBinding
|
|
22
|
+
export function parseOperationReceipt(value: unknown): OperationReceipt
|
|
23
|
+
|
|
24
|
+
export interface ProviderOperationProof extends InstallationBinding { schema: 'mnstry.extension-provider-operation/v1'; operationId: string; actionId: string; payloadDigest: string; resultDigest: string; result: Json }
|
|
25
|
+
export function parseProviderOperationProof(value: unknown): ProviderOperationProof
|
|
26
|
+
|
|
27
|
+
export interface ExtensionReleaseBundle { manifest: ExtensionManifest; schemaFiles: Record<string, string>; migrationDigests: Record<string, string> }
|
|
28
|
+
export interface ExtensionReleaseRegistration { releaseId: string; releaseDigest: string }
|
|
29
|
+
export function parseReleaseBundle(value: unknown): ExtensionReleaseBundle
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Portable wire contracts. Host authentication and persistence stay with the consumer.
|
|
2
|
+
export class ExtensionError extends Error {
|
|
3
|
+
constructor(code, status = 400) { super(code); this.name = 'ExtensionError'; this.code = code; this.status = status }
|
|
4
|
+
}
|
|
5
|
+
const reject = () => { throw new ExtensionError('invalid-input') }
|
|
6
|
+
export function canonicalJson(value) {
|
|
7
|
+
const seen = new Set()
|
|
8
|
+
function visit(item, depth) {
|
|
9
|
+
if (depth > 16) return reject()
|
|
10
|
+
if (item === null || typeof item === 'boolean') return JSON.stringify(item)
|
|
11
|
+
if (typeof item === 'number') return Number.isFinite(item) ? JSON.stringify(item) : reject()
|
|
12
|
+
if (typeof item === 'string') {
|
|
13
|
+
for (let n = 0; n < item.length; n++) {
|
|
14
|
+
const c = item.charCodeAt(n)
|
|
15
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
16
|
+
const next = item.charCodeAt(++n)
|
|
17
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return reject()
|
|
18
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) return reject()
|
|
19
|
+
}
|
|
20
|
+
return JSON.stringify(item)
|
|
21
|
+
}
|
|
22
|
+
if (!item || typeof item !== 'object' || seen.has(item)) return reject()
|
|
23
|
+
seen.add(item)
|
|
24
|
+
let result
|
|
25
|
+
if (Array.isArray(item)) {
|
|
26
|
+
if (Reflect.ownKeys(item).length !== item.length + 1 || Array.from({ length: item.length }, (_, i) => i).some(i => !Object.hasOwn(item, i) || !Object.hasOwn(Object.getOwnPropertyDescriptor(item, String(i)), 'value'))) return reject()
|
|
27
|
+
result = `[${item.map(x => visit(x, depth + 1)).join(',')}]`
|
|
28
|
+
} else {
|
|
29
|
+
if (Object.getPrototypeOf(item) !== Object.prototype || Reflect.ownKeys(item).some(k => typeof k !== 'string')) return reject()
|
|
30
|
+
const keys = Reflect.ownKeys(item).sort()
|
|
31
|
+
if (keys.some(k => !Object.getOwnPropertyDescriptor(item, k).enumerable)) return reject()
|
|
32
|
+
if (keys.some(k => !Object.hasOwn(Object.getOwnPropertyDescriptor(item, k), 'value'))) return reject()
|
|
33
|
+
result = `{${keys.map(k => `${visit(k, depth + 1)}:${visit(item[k], depth + 1)}`).join(',')}}`
|
|
34
|
+
}
|
|
35
|
+
seen.delete(item)
|
|
36
|
+
return result
|
|
37
|
+
}
|
|
38
|
+
const encoded = visit(value, 0)
|
|
39
|
+
if (new TextEncoder().encode(encoded).length > 131072) return reject()
|
|
40
|
+
return encoded
|
|
41
|
+
}
|
|
42
|
+
export function text(value, limit = 128) {
|
|
43
|
+
if (typeof value !== 'string' || !value.length || [...value].length > limit || /[\u0000-\u001f]/u.test(value)) return reject()
|
|
44
|
+
canonicalJson(value)
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
export function exact(value, required, optional = []) {
|
|
48
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype ||
|
|
49
|
+
required.some(k => !Object.hasOwn(value, k)) || Object.keys(value).some(k => !required.includes(k) && !optional.includes(k))) return reject()
|
|
50
|
+
return value
|
|
51
|
+
}
|
|
52
|
+
const digest = value => /^[a-f0-9]{64}$/.test(text(value, 64)) ? value : reject()
|
|
53
|
+
const strings = (value, max = 64) => {
|
|
54
|
+
if (!Array.isArray(value) || value.length > max || value.some(x => typeof x !== 'string') || new Set(value).size !== value.length) return reject()
|
|
55
|
+
return value.map(x => text(x, 256))
|
|
56
|
+
}
|
|
57
|
+
const schemaRef = value => {
|
|
58
|
+
text(value, 256)
|
|
59
|
+
if (!/^schemas\/[a-zA-Z0-9._-]+\.json$/.test(value) || value.startsWith('/') || value.split('/').some(x => !x || x === '.' || x === '..')) return reject()
|
|
60
|
+
return value
|
|
61
|
+
}
|
|
62
|
+
function manifestObject(value, required, optional = []) {
|
|
63
|
+
exact(value, required, [...optional, 'ext'])
|
|
64
|
+
if (value.ext !== undefined) exact(value.ext, [])
|
|
65
|
+
}
|
|
66
|
+
export function parseManifest(value) {
|
|
67
|
+
const m = JSON.parse(canonicalJson(value))
|
|
68
|
+
manifestObject(m, ['schema', 'extensionId', 'releaseVersion', 'releaseDigest', 'protocol', 'compatibility', 'deployment', 'actions', 'permissions', 'surfaces', 'data'], ['recovery', 'contractVersion'])
|
|
69
|
+
if (m.contractVersion !== undefined && !/^1\.[0-9]+\.[0-9]+$/.test(text(m.contractVersion))) return reject()
|
|
70
|
+
if (m.schema !== 'mnstry.extension-manifest/v1' || m.protocol !== 'mnstry.extension/v1' ||
|
|
71
|
+
!/^[a-z0-9][a-z0-9._-]{2,127}$/.test(text(m.extensionId)) ||
|
|
72
|
+
!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(text(m.releaseVersion))) return reject()
|
|
73
|
+
const prerelease = m.releaseVersion.split('+')[0].split('-').slice(1).join('-')
|
|
74
|
+
if (prerelease.split('.').some(part => /^0\d+$/.test(part))) return reject()
|
|
75
|
+
digest(m.releaseDigest)
|
|
76
|
+
manifestObject(m.compatibility, ['sdkMajor', 'rootContracts', 'hosts'])
|
|
77
|
+
if (m.compatibility.sdkMajor !== 1 || !m.compatibility.hosts.length || strings(m.compatibility.hosts).some(x => !['web', 'native'].includes(x))) return reject()
|
|
78
|
+
strings(m.compatibility.rootContracts)
|
|
79
|
+
manifestObject(m.deployment, ['profile', 'bundleDigest', 'schemaDigest'])
|
|
80
|
+
if (!['postgres-sql', 'drizzle-db-first', 'drizzle-code-first', 'prisma-7-postgres', 'prisma-8-postgres-experimental', 'convex-native'].includes(text(m.deployment.profile))) return reject()
|
|
81
|
+
digest(m.deployment.bundleDigest); digest(m.deployment.schemaDigest)
|
|
82
|
+
if (!Array.isArray(m.actions) || !m.actions.length || m.actions.length > 64) return reject()
|
|
83
|
+
const actions = new Map()
|
|
84
|
+
for (const action of m.actions) {
|
|
85
|
+
manifestObject(action, ['id', 'effect', 'scope', 'inputSchemaRef', 'outputSchemaRef', 'timeoutMs'])
|
|
86
|
+
text(action.id)
|
|
87
|
+
if (!/^[a-z0-9][a-z0-9._-]{2,127}$/.test(action.id) || actions.has(action.id) || /^(?:lifecycle|installation|extension)\./.test(action.id) ||
|
|
88
|
+
!['read', 'write'].includes(action.effect) || !['member', 'tenant'].includes(action.scope) ||
|
|
89
|
+
!Number.isInteger(action.timeoutMs) || action.timeoutMs < 1 || action.timeoutMs > 30000) return reject()
|
|
90
|
+
schemaRef(action.inputSchemaRef); schemaRef(action.outputSchemaRef); actions.set(action.id, action)
|
|
91
|
+
}
|
|
92
|
+
if (m.recovery !== undefined) {
|
|
93
|
+
manifestObject(m.recovery, ['lookupActionId'])
|
|
94
|
+
const action = actions.get(m.recovery.lookupActionId)
|
|
95
|
+
if (!action || action.effect !== 'read' || action.scope !== 'member') return reject()
|
|
96
|
+
}
|
|
97
|
+
manifestObject(m.permissions, ['rootReads', 'rootWrites', 'egress'])
|
|
98
|
+
for (const key of ['rootReads', 'rootWrites', 'egress']) strings(m.permissions[key])
|
|
99
|
+
if (!Array.isArray(m.surfaces) || m.surfaces.length > 32) return reject()
|
|
100
|
+
const surfaces = new Set()
|
|
101
|
+
for (const surface of m.surfaces) {
|
|
102
|
+
manifestObject(surface, ['id', 'compositionDigest', 'actionIds']); text(surface.id); digest(surface.compositionDigest)
|
|
103
|
+
if (!/^[a-z0-9][a-z0-9._-]{2,127}$/.test(surface.id) || surfaces.has(surface.id) || strings(surface.actionIds).some(id => !actions.has(id))) return reject()
|
|
104
|
+
surfaces.add(surface.id)
|
|
105
|
+
}
|
|
106
|
+
manifestObject(m.data, ['classes', 'retention', 'exportVersion', 'schemaVersion', 'readableSchemaVersions'])
|
|
107
|
+
strings(m.data.classes); text(m.data.schemaVersion)
|
|
108
|
+
if (m.data.retention !== 'until-user-delete' || m.data.exportVersion !== 1 ||
|
|
109
|
+
!strings(m.data.readableSchemaVersions).includes(m.data.schemaVersion)) return reject()
|
|
110
|
+
return m
|
|
111
|
+
}
|
|
112
|
+
export function parseInstallationBinding(value) {
|
|
113
|
+
value = JSON.parse(canonicalJson(value))
|
|
114
|
+
exact(value, ['installationId', 'generation', 'releaseDigest'])
|
|
115
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(text(value.installationId)) ||
|
|
116
|
+
!/^[1-9][0-9]{0,18}$/.test(text(value.generation)) || BigInt(value.generation) > 9223372036854775807n) return reject()
|
|
117
|
+
digest(value.releaseDigest)
|
|
118
|
+
return { ...value }
|
|
119
|
+
}
|
|
120
|
+
export function parseOperationReceipt(value) {
|
|
121
|
+
value = JSON.parse(canonicalJson(value))
|
|
122
|
+
exact(value, ['operationId', 'state'], ['result', 'resultDigest'])
|
|
123
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(text(value.operationId)) || !['pending', 'admitted', 'succeeded', 'unknown', 'failed'].includes(value.state)) return reject()
|
|
124
|
+
if (value.state === 'succeeded') {
|
|
125
|
+
if (!Object.hasOwn(value, 'result')) return reject()
|
|
126
|
+
digest(value.resultDigest); canonicalJson(value.result)
|
|
127
|
+
} else if (Object.hasOwn(value, 'result') || Object.hasOwn(value, 'resultDigest')) return reject()
|
|
128
|
+
return JSON.parse(canonicalJson(value))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function parseProviderOperationProof(value) {
|
|
132
|
+
const proof = JSON.parse(canonicalJson(value))
|
|
133
|
+
exact(proof, ['schema', 'installationId', 'generation', 'releaseDigest', 'operationId', 'actionId', 'payloadDigest', 'resultDigest', 'result'])
|
|
134
|
+
if (proof.schema !== 'mnstry.extension-provider-operation/v1' || !/^[A-Za-z0-9_-]{16,128}$/.test(text(proof.operationId))) return reject()
|
|
135
|
+
parseInstallationBinding({ installationId: proof.installationId, generation: proof.generation, releaseDigest: proof.releaseDigest })
|
|
136
|
+
text(proof.actionId); digest(proof.payloadDigest); digest(proof.resultDigest)
|
|
137
|
+
return proof
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
/** Portable registration input. The receiving host independently verifies the
|
|
142
|
+
* release and file hashes; this parser performs no I/O or migration execution. */
|
|
143
|
+
export function parseReleaseBundle(value) {
|
|
144
|
+
const bundle=exact(JSON.parse(canonicalJson(value)),['manifest','schemaFiles','migrationDigests'])
|
|
145
|
+
bundle.manifest=parseManifest(bundle.manifest)
|
|
146
|
+
for(const field of ['schemaFiles','migrationDigests']) {
|
|
147
|
+
const map=bundle[field]
|
|
148
|
+
if(!map||typeof map!=='object'||Array.isArray(map)||Object.getPrototypeOf(map)!==Object.prototype)throw new ExtensionError('invalid-input')
|
|
149
|
+
}
|
|
150
|
+
if(Object.keys(bundle.schemaFiles).length>256)throw new ExtensionError('invalid-input')
|
|
151
|
+
for(const [name,bytes] of Object.entries(bundle.schemaFiles)) {
|
|
152
|
+
if(!/^schemas\/[A-Za-z0-9._-]+\.json$/.test(name)||typeof bytes!=='string'||new TextEncoder().encode(bytes).length>65536)throw new ExtensionError('invalid-input')
|
|
153
|
+
let schema
|
|
154
|
+
try{schema=JSON.parse(bytes)}catch{throw new ExtensionError('invalid-input')}
|
|
155
|
+
if(typeof schema!=='boolean'&&(!schema||typeof schema!=='object'||Array.isArray(schema)))throw new ExtensionError('invalid-input')
|
|
156
|
+
canonicalJson(schema)
|
|
157
|
+
}
|
|
158
|
+
for(const [name,digest] of Object.entries(bundle.migrationDigests)) {
|
|
159
|
+
if(!/^migrations\/[A-Za-z0-9._/-]+$/.test(name)||name.split('/').some(part=>!part||part==='.'||part==='..')||typeof digest!=='string'||!/^[a-f0-9]{64}$/.test(digest))throw new ExtensionError('invalid-input')
|
|
160
|
+
}
|
|
161
|
+
for(const action of bundle.manifest.actions)for(const field of ['inputSchemaRef','outputSchemaRef'])if(!Object.hasOwn(bundle.schemaFiles,action[field]))throw new ExtensionError('invalid-input')
|
|
162
|
+
return bundle
|
|
163
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { GenericDataModel, GenericMutationCtx, GenericQueryCtx } from 'convex/server'
|
|
2
|
+
import type { ExtensionManifest, Json, OperationReceipt, ProviderOperationProof } from './contracts.mjs'
|
|
3
|
+
import type { VerifiedScope } from './server.mjs'
|
|
4
|
+
export interface ConvexVerifierOptions {
|
|
5
|
+
issuer: string; audience: string; installationId: string; releaseDigest: string;
|
|
6
|
+
keys: ReadonlyMap<string, JsonWebKey>; now?: () => number;
|
|
7
|
+
consume?(claims: Readonly<Record<string, unknown>>, delegation: string): Promise<boolean>;
|
|
8
|
+
}
|
|
9
|
+
export interface ConvexActionDefinition<I = Json, O = Json, D extends GenericDataModel = GenericDataModel> {
|
|
10
|
+
input(value: unknown): I; output(value: unknown): O;
|
|
11
|
+
run(context: { context: GenericMutationCtx<D>; scope: VerifiedScope }, input: I): unknown | Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface ConvexExtension { readonly manifest: ExtensionManifest }
|
|
14
|
+
export function convexDigest(value: unknown): Promise<string>
|
|
15
|
+
export function assertConvexScope(scope: VerifiedScope): void
|
|
16
|
+
export function defineConvexExtension<D extends GenericDataModel = GenericDataModel>(options: {
|
|
17
|
+
manifest: ExtensionManifest; verifier: ConvexVerifierOptions; actions: Record<string, ConvexActionDefinition<any, any, D>>;
|
|
18
|
+
}): ConvexExtension
|
|
19
|
+
export function createRootAdmissionConsumer(options: { origin: string; allowLocalTesting?: boolean; fetch?: typeof fetch }): NonNullable<ConvexVerifierOptions['consume']>
|
|
20
|
+
export function createConvexHttpHandler(extension: ConvexExtension, invoke: (args: { delegation: string; payload: Json }) => Promise<OperationReceipt>): (request: Request) => Promise<Response>
|
|
21
|
+
export function runConvexInvocation<D extends GenericDataModel>(extension: ConvexExtension, context: GenericMutationCtx<D>, args: { delegation: string; payload: Json }): Promise<OperationReceipt>
|
|
22
|
+
export function onceConvexOperation<D extends GenericDataModel>(context: GenericMutationCtx<D>, scope: VerifiedScope, input: Json, run: (context: GenericMutationCtx<D>) => Promise<Json>): Promise<Json>
|
|
23
|
+
export function lookupConvexOperation<D extends GenericDataModel>(context: GenericMutationCtx<D>, scope: VerifiedScope, operationId: string): Promise<ProviderOperationProof | null>
|
|
24
|
+
export function convexHealth<D extends GenericDataModel>(context: GenericQueryCtx<D>, manifest: ExtensionManifest): Promise<{ schema: 'mnstry.extension-health/v1'; ready: true; profile: 'convex-native'; releaseDigest: string; schemaDigest: string }>
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Native Convex functions use Web Crypto and the existing delegation protocol.
|
|
2
|
+
// This module imports no Node runtime, database driver or Convex admin client.
|
|
3
|
+
import { canonicalJson, exact, ExtensionError, parseManifest, parseOperationReceipt, text } from './contracts.mjs'
|
|
4
|
+
import { assertDelegationBinding, decodeBase64Url, decodeDelegation, delegationScope, unauthorized, validateDelegationClaims } from './delegation.mjs'
|
|
5
|
+
import { createAdmissionConsumer, readInvocationBody } from './admission.mjs'
|
|
6
|
+
|
|
7
|
+
// Convex bundles may replace NODE_ENV at build time. Authorize the optional
|
|
8
|
+
// loopback test transport only when BOTH provider-owned deployment URLs are local.
|
|
9
|
+
export function createRootAdmissionConsumer(options) {
|
|
10
|
+
const localRuntime = ['CONVEX_CLOUD_URL', 'CONVEX_SITE_URL'].every(name => {
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(process.env[name])
|
|
13
|
+
return url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname) &&
|
|
14
|
+
!url.username && !url.password && url.pathname === '/' && !url.search && !url.hash
|
|
15
|
+
} catch { return false }
|
|
16
|
+
})
|
|
17
|
+
return createAdmissionConsumer(options, options.allowLocalTesting === true && localRuntime)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const definitions = new WeakMap()
|
|
21
|
+
const scopes = new WeakMap()
|
|
22
|
+
const encoder = new TextEncoder()
|
|
23
|
+
const freeze = value => {
|
|
24
|
+
if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) }
|
|
25
|
+
return value
|
|
26
|
+
}
|
|
27
|
+
export async function convexDigest(value) {
|
|
28
|
+
const bytes = await crypto.subtle.digest('SHA-256', encoder.encode(canonicalJson(value)))
|
|
29
|
+
return [...new Uint8Array(bytes)].map(byte => byte.toString(16).padStart(2, '0')).join('')
|
|
30
|
+
}
|
|
31
|
+
export function assertConvexScope(scope) {
|
|
32
|
+
if (!scope || !scopes.get(scope)?.()) return unauthorized()
|
|
33
|
+
}
|
|
34
|
+
export function defineConvexExtension({ manifest, verifier, actions }) {
|
|
35
|
+
const pinned = parseManifest(manifest)
|
|
36
|
+
if (pinned.deployment.profile !== 'convex-native' || pinned.actions.some(action => !Object.hasOwn(actions, action.id)) ||
|
|
37
|
+
Object.keys(actions).some(id => !pinned.actions.some(action => action.id === id))) throw new ExtensionError('invalid-input')
|
|
38
|
+
const keys = new Map()
|
|
39
|
+
for (const [id, value] of verifier.keys) {
|
|
40
|
+
text(id)
|
|
41
|
+
const jwk = exact(JSON.parse(canonicalJson(value)), ['kty', 'crv', 'x', 'y'], ['alg', 'use', 'key_ops', 'ext', 'kid'])
|
|
42
|
+
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || (jwk.alg !== undefined && jwk.alg !== 'ES256') ||
|
|
43
|
+
(jwk.use !== undefined && jwk.use !== 'sig') || (jwk.key_ops !== undefined && (jwk.key_ops.length !== 1 || jwk.key_ops[0] !== 'verify')) ||
|
|
44
|
+
decodeBase64Url(jwk.x).length !== 32 || decodeBase64Url(jwk.y).length !== 32) throw new ExtensionError('invalid-input')
|
|
45
|
+
keys.set(id, freeze(jwk))
|
|
46
|
+
}
|
|
47
|
+
const normalized = Object.fromEntries(Object.entries(actions).map(([id, action]) => {
|
|
48
|
+
if (['input', 'output', 'run'].some(key => typeof action[key] !== 'function')) throw new ExtensionError('invalid-input')
|
|
49
|
+
return [id, Object.freeze({ input: action.input, output: action.output, run: action.run })]
|
|
50
|
+
}))
|
|
51
|
+
const runtime = Object.freeze({ manifest: freeze(pinned), verifier: Object.freeze({ ...verifier, keys }), actions: Object.freeze(normalized) })
|
|
52
|
+
const handle = Object.freeze({ manifest: runtime.manifest })
|
|
53
|
+
definitions.set(handle, runtime)
|
|
54
|
+
return handle
|
|
55
|
+
}
|
|
56
|
+
function definition(handle) {
|
|
57
|
+
const extension = definitions.get(handle)
|
|
58
|
+
if (!extension) throw new ExtensionError('invalid-input')
|
|
59
|
+
return extension
|
|
60
|
+
}
|
|
61
|
+
async function invocation(extension, delegation, payload) {
|
|
62
|
+
let claims
|
|
63
|
+
try {
|
|
64
|
+
const decoded = decodeDelegation(delegation)
|
|
65
|
+
const jwk = extension.verifier.keys.get(decoded.header.kid)
|
|
66
|
+
if (!jwk) return unauthorized()
|
|
67
|
+
const key = await crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'])
|
|
68
|
+
if (!await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, decoded.signature, decoded.signed)) return unauthorized()
|
|
69
|
+
claims = validateDelegationClaims(decoded.claims, extension.verifier)
|
|
70
|
+
assertDelegationBinding(claims, extension.manifest, extension.verifier)
|
|
71
|
+
} catch { return unauthorized() }
|
|
72
|
+
const { releaseDigest: ignored, ...manifestBody } = extension.manifest
|
|
73
|
+
if (await convexDigest(manifestBody) !== extension.manifest.releaseDigest) throw new ExtensionError('invalid-input')
|
|
74
|
+
const declared = extension.manifest.actions.find(action => action.id === claims.actionId)
|
|
75
|
+
const action = Object.hasOwn(extension.actions, claims.actionId) ? extension.actions[claims.actionId] : null
|
|
76
|
+
if (!declared || !action) return unauthorized()
|
|
77
|
+
const encoded = canonicalJson(payload)
|
|
78
|
+
if (encoder.encode(encoded).length > 65536 || await convexDigest(payload) !== claims.payloadDigest) throw new ExtensionError('invalid-input')
|
|
79
|
+
const input = action.input(JSON.parse(encoded))
|
|
80
|
+
if (await convexDigest(input) !== claims.payloadDigest) throw new ExtensionError('invalid-input')
|
|
81
|
+
return { claims, declared, action, input }
|
|
82
|
+
}
|
|
83
|
+
function boundedResult(value, lookup = false) {
|
|
84
|
+
const encoded = canonicalJson(value)
|
|
85
|
+
if (encoder.encode(encoded).length > (lookup ? 65536 : 60 * 1024)) throw new ExtensionError('invalid-input')
|
|
86
|
+
// Leave the same nesting headroom required by the host's result/proof wrappers.
|
|
87
|
+
canonicalJson(lookup ? { result: value } : { result: { result: value } })
|
|
88
|
+
return JSON.parse(encoded)
|
|
89
|
+
}
|
|
90
|
+
/** Call ONLY from a registered internalMutation. Never accept a serialized scope.
|
|
91
|
+
* Convex's transaction atomically commits all native reads/writes or rolls back.
|
|
92
|
+
* Date.now is Convex's transaction-start time; this does not revoke an in-flight commit. */
|
|
93
|
+
export async function runConvexInvocation(handle, context, args) {
|
|
94
|
+
exact(args, ['delegation', 'payload'])
|
|
95
|
+
const extension = definition(handle)
|
|
96
|
+
const { claims, declared, action, input } = await invocation(extension, args.delegation, args.payload)
|
|
97
|
+
const scope = delegationScope(claims)
|
|
98
|
+
const deadline = Math.min(claims.exp * 1000, (extension.verifier.now?.() ?? Date.now()) + declared.timeoutMs)
|
|
99
|
+
scopes.set(scope, () => deadline > (extension.verifier.now?.() ?? Date.now()))
|
|
100
|
+
try {
|
|
101
|
+
assertConvexScope(scope)
|
|
102
|
+
const lookup = claims.actionId === extension.manifest.recovery?.lookupActionId
|
|
103
|
+
const original = canonicalJson(boundedResult(await action.run({ context, scope }, input), lookup))
|
|
104
|
+
const result = boundedResult(action.output(JSON.parse(original)), lookup)
|
|
105
|
+
// Output validators may refuse a value, but cannot silently change the
|
|
106
|
+
// durable result that onceConvexOperation will expose during recovery.
|
|
107
|
+
if (canonicalJson(result) !== original) throw new ExtensionError('invalid-input')
|
|
108
|
+
const resultDigest = await convexDigest(result)
|
|
109
|
+
assertConvexScope(scope)
|
|
110
|
+
return { operationId: claims.operationId, state: 'succeeded', result, resultDigest }
|
|
111
|
+
} finally { scopes.delete(scope) }
|
|
112
|
+
}
|
|
113
|
+
/** HTTP action: check original delegation, consume Root admission once, then call
|
|
114
|
+
* the private native mutation with the ORIGINAL token and canonical payload. */
|
|
115
|
+
export function createConvexHttpHandler(handle, invoke) {
|
|
116
|
+
const extension = definition(handle)
|
|
117
|
+
if (typeof invoke !== 'function' || typeof extension.verifier.consume !== 'function') throw new ExtensionError('invalid-input')
|
|
118
|
+
return async request => {
|
|
119
|
+
let admitted = false
|
|
120
|
+
try {
|
|
121
|
+
if (request.method !== 'POST' || request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return new Response(null, { status: 415 })
|
|
122
|
+
const delegation = request.headers.get('authorization')?.match(/^Bearer ([A-Za-z0-9_.-]+)$/)?.[1]
|
|
123
|
+
if (!delegation) return unauthorized()
|
|
124
|
+
const { claims, declared, input } = await invocation(extension, delegation, await readInvocationBody(request))
|
|
125
|
+
if (!await extension.verifier.consume(claims, delegation)) throw new ExtensionError('forbidden', 403)
|
|
126
|
+
admitted = true
|
|
127
|
+
const deadline = Math.min(claims.exp * 1000, (extension.verifier.now?.() ?? Date.now()) + declared.timeoutMs)
|
|
128
|
+
if (deadline <= (extension.verifier.now?.() ?? Date.now())) throw new ExtensionError('operation-unknown', 409)
|
|
129
|
+
const receipt = parseOperationReceipt(await invoke({ delegation, payload: input }))
|
|
130
|
+
if (receipt.state !== 'succeeded' || receipt.operationId !== claims.operationId || await convexDigest(receipt.result) !== receipt.resultDigest ||
|
|
131
|
+
deadline <= (extension.verifier.now?.() ?? Date.now())) throw new ExtensionError('operation-unknown', 409)
|
|
132
|
+
boundedResult(receipt.result, claims.actionId === extension.manifest.recovery?.lookupActionId)
|
|
133
|
+
return Response.json(receipt, { headers: { 'cache-control': 'no-store' } })
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const statuses = { 'invalid-input': 400, unauthorized: 401, forbidden: 403, 'operation-unknown': 409, unavailable: 503 }
|
|
136
|
+
const code = admitted ? 'operation-unknown' : error instanceof ExtensionError && Object.hasOwn(statuses, error.code) ? error.code : 'unavailable'
|
|
137
|
+
return Response.json({ code }, { status: statuses[code], headers: { 'cache-control': 'no-store' } })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function scopedQuery(context, table, index, scope, operationId) {
|
|
142
|
+
assertConvexScope(scope)
|
|
143
|
+
return context.db.query(table).withIndex(index, query => {
|
|
144
|
+
const scoped = query.eq('installationId', scope.installationId).eq('tenantRef', scope.tenantRef).eq('subjectRef', scope.subjectRef)
|
|
145
|
+
return operationId === undefined ? scoped : scoped.eq('operationId', operationId)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
/** Native indexed read/OCC conflict plus result insert, within ONE internal mutation. */
|
|
149
|
+
export async function onceConvexOperation(context, scope, input, run) {
|
|
150
|
+
assertConvexScope(scope)
|
|
151
|
+
if (await convexDigest(input) !== scope.payloadDigest) throw new ExtensionError('invalid-input')
|
|
152
|
+
const row = await scopedQuery(context, 'extensionOperations', 'by_scope_operation', scope, scope.operationId).unique()
|
|
153
|
+
assertConvexScope(scope)
|
|
154
|
+
if (row) {
|
|
155
|
+
if (row.actionId !== scope.actionId || row.generation !== scope.generation || row.releaseDigest !== scope.releaseDigest || row.payloadDigest !== scope.payloadDigest) throw new ExtensionError('idempotency-conflict', 409)
|
|
156
|
+
if (await convexDigest(row.result) !== row.resultDigest) throw new ExtensionError('unavailable', 503)
|
|
157
|
+
return boundedResult(row.result)
|
|
158
|
+
}
|
|
159
|
+
const result = boundedResult(await run(context))
|
|
160
|
+
const resultDigest = await convexDigest(result)
|
|
161
|
+
assertConvexScope(scope)
|
|
162
|
+
const { rootWrites: omitted, ...identity } = scope
|
|
163
|
+
await context.db.insert('extensionOperations', { ...identity, result, resultDigest })
|
|
164
|
+
assertConvexScope(scope)
|
|
165
|
+
return result
|
|
166
|
+
}
|
|
167
|
+
export async function lookupConvexOperation(context, scope, operationId) {
|
|
168
|
+
assertConvexScope(scope)
|
|
169
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(text(operationId))) throw new ExtensionError('invalid-input')
|
|
170
|
+
const row = await scopedQuery(context, 'extensionOperations', 'by_scope_operation', scope, operationId).unique()
|
|
171
|
+
assertConvexScope(scope)
|
|
172
|
+
if (!row) return null
|
|
173
|
+
if (await convexDigest(row.result) !== row.resultDigest) throw new ExtensionError('unavailable', 503)
|
|
174
|
+
return { schema: 'mnstry.extension-provider-operation/v1', installationId: scope.installationId,
|
|
175
|
+
operationId: row.operationId, actionId: row.actionId, generation: row.generation, releaseDigest: row.releaseDigest,
|
|
176
|
+
payloadDigest: row.payloadDigest, resultDigest: row.resultDigest, result: row.result }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Read-only native index readiness; the existing host health envelope carries
|
|
180
|
+
* only immutable release/schema identities, never member rows or row counts. */
|
|
181
|
+
export async function convexHealth(context, value) {
|
|
182
|
+
const manifest = parseManifest(value)
|
|
183
|
+
const { releaseDigest: ignored, ...body } = manifest
|
|
184
|
+
if (manifest.deployment.profile !== 'convex-native' || await convexDigest(body) !== manifest.releaseDigest) throw new ExtensionError('unavailable', 503)
|
|
185
|
+
await context.db.query('extensionCounters').withIndex('by_scope').take(0)
|
|
186
|
+
await context.db.query('extensionOperations').withIndex('by_scope_operation').take(0)
|
|
187
|
+
return { schema: 'mnstry.extension-health/v1', ready: true, profile: 'convex-native',
|
|
188
|
+
releaseDigest: manifest.releaseDigest, schemaDigest: manifest.deployment.schemaDigest }
|
|
189
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// One delegation contract shared by Node and provider-native runtimes.
|
|
2
|
+
import { exact, ExtensionError, text } from './contracts.mjs'
|
|
3
|
+
|
|
4
|
+
export const unauthorized = () => { throw new ExtensionError('unauthorized', 401) }
|
|
5
|
+
const claimNames = ['iss', 'aud', 'iat', 'nbf', 'exp', 'jti', 'purpose', 'installationId', 'tenantRef', 'subjectRef', 'generation', 'releaseDigest', 'operationId', 'actionId', 'payloadDigest', 'rootWrites']
|
|
6
|
+
const opaque = value => /^[A-Za-z0-9_-]{16,128}$/.test(text(value))
|
|
7
|
+
export function decodeBase64Url(part) {
|
|
8
|
+
if (typeof part !== 'string' || !/^[A-Za-z0-9_-]+$/.test(part)) return unauthorized()
|
|
9
|
+
const binary = atob(part.replace(/-/g, '+').replace(/_/g, '/'))
|
|
10
|
+
if (btoa(binary).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') !== part) return unauthorized()
|
|
11
|
+
return Uint8Array.from(binary, char => char.charCodeAt(0))
|
|
12
|
+
}
|
|
13
|
+
function decodeJson(part) {
|
|
14
|
+
const bytes = decodeBase64Url(part)
|
|
15
|
+
const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
16
|
+
const encoded = new TextEncoder().encode(value)
|
|
17
|
+
if (encoded.length !== bytes.length || encoded.some((byte, index) => byte !== bytes[index])) return unauthorized()
|
|
18
|
+
return JSON.parse(value)
|
|
19
|
+
}
|
|
20
|
+
export function decodeDelegation(delegation) {
|
|
21
|
+
text(delegation, 16384)
|
|
22
|
+
const parts = delegation.split('.')
|
|
23
|
+
if (parts.length !== 3) return unauthorized()
|
|
24
|
+
const header = exact(decodeJson(parts[0]), ['alg', 'typ', 'kid'])
|
|
25
|
+
if (header.alg !== 'ES256' || header.typ !== 'JWT') return unauthorized()
|
|
26
|
+
text(header.kid)
|
|
27
|
+
const signature = decodeBase64Url(parts[2])
|
|
28
|
+
if (signature.length !== 64) return unauthorized()
|
|
29
|
+
return { header, claims: decodeJson(parts[1]), signature, signed: new TextEncoder().encode(`${parts[0]}.${parts[1]}`) }
|
|
30
|
+
}
|
|
31
|
+
export function validateDelegationClaims(value, options) {
|
|
32
|
+
const claims = exact(value, claimNames)
|
|
33
|
+
const now = Math.floor((options.now?.() ?? Date.now()) / 1000)
|
|
34
|
+
if (claims.iss !== options.issuer || claims.aud !== options.audience || claims.purpose !== 'extension.invoke/v1' ||
|
|
35
|
+
![claims.iat, claims.nbf, claims.exp].every(Number.isSafeInteger) || claims.iat > now || claims.nbf !== claims.iat ||
|
|
36
|
+
claims.exp <= now || claims.exp <= claims.iat || claims.exp - claims.iat > 60) return unauthorized()
|
|
37
|
+
for (const field of ['jti', 'installationId', 'operationId', 'tenantRef', 'subjectRef']) if (!opaque(claims[field])) return unauthorized()
|
|
38
|
+
text(claims.actionId)
|
|
39
|
+
if (typeof claims.generation !== 'string' || typeof claims.releaseDigest !== 'string' || typeof claims.payloadDigest !== 'string' || !/^[1-9][0-9]{0,18}$/.test(claims.generation) || BigInt(claims.generation) > 9223372036854775807n ||
|
|
40
|
+
!/^[a-f0-9]{64}$/.test(claims.releaseDigest) || !/^[a-f0-9]{64}$/.test(claims.payloadDigest) ||
|
|
41
|
+
!Array.isArray(claims.rootWrites) || claims.rootWrites.length > 64 || new Set(claims.rootWrites).size !== claims.rootWrites.length) return unauthorized()
|
|
42
|
+
claims.rootWrites.forEach(value => text(value, 256))
|
|
43
|
+
return Object.freeze({ ...claims, rootWrites: Object.freeze([...claims.rootWrites]) })
|
|
44
|
+
}
|
|
45
|
+
export function assertDelegationBinding(claims, manifest, verifier) {
|
|
46
|
+
if (claims.installationId !== verifier.installationId || claims.releaseDigest !== manifest.releaseDigest ||
|
|
47
|
+
claims.releaseDigest !== verifier.releaseDigest || claims.rootWrites.length !== manifest.permissions.rootWrites.length ||
|
|
48
|
+
claims.rootWrites.some(value => !manifest.permissions.rootWrites.includes(value))) return unauthorized()
|
|
49
|
+
}
|
|
50
|
+
export function delegationScope(claims) {
|
|
51
|
+
return Object.freeze({ installationId: claims.installationId, generation: claims.generation, releaseDigest: claims.releaseDigest,
|
|
52
|
+
operationId: claims.operationId, actionId: claims.actionId, payloadDigest: claims.payloadDigest,
|
|
53
|
+
tenantRef: claims.tenantRef, subjectRef: claims.subjectRef, rootWrites: claims.rootWrites })
|
|
54
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Sql } from 'postgres'
|
|
2
|
+
import type { Json, ProviderOperationProof } from './contracts.mjs'
|
|
3
|
+
import type { VerifiedScope } from './server.mjs'
|
|
4
|
+
export interface DrizzleTransaction { execute(query: any): PromiseLike<any> }
|
|
5
|
+
/** Native drizzle-orm/postgres-js database; other drivers are unsupported. */
|
|
6
|
+
export interface DrizzleDatabase<Transaction extends DrizzleTransaction> { readonly $client: Sql; transaction<T>(run:(transaction:Transaction)=>Promise<T>):Promise<T> }
|
|
7
|
+
export type DrizzleSqlTag = (chunks:TemplateStringsArray,...values:any[])=>unknown
|
|
8
|
+
export function withDrizzleScope<Transaction extends DrizzleTransaction,T>(database: DrizzleDatabase<Transaction>, scope: VerifiedScope, sql: DrizzleSqlTag, run: (transaction: Transaction) => Promise<T>): Promise<T>
|
|
9
|
+
export function onceDrizzleOperation<Transaction extends DrizzleTransaction>(database: DrizzleDatabase<Transaction>, scope: VerifiedScope, input: Json, sql: DrizzleSqlTag, run: (transaction: Transaction) => Promise<Json>): Promise<Json>
|
|
10
|
+
export function lookupDrizzleOperation<Transaction extends DrizzleTransaction>(database:DrizzleDatabase<Transaction>,scope:VerifiedScope,sql:DrizzleSqlTag,operationId:string):Promise<ProviderOperationProof|null>
|
|
11
|
+
export function drizzleHealth(driver: Sql, profile: 'drizzle-db-first'|'drizzle-code-first'): Promise<{ ready: true; profile: 'drizzle-db-first'|'drizzle-code-first'; postgresMajor: 17 }>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { withPostgresScope, oncePostgresOperation, lookupPostgresOperation, postgresHealth } from './postgres.mjs'
|
|
2
|
+
import { ExtensionError } from './contracts.mjs'
|
|
3
|
+
|
|
4
|
+
// The native ORM owns the transaction. The application supplies its SQL tag;
|
|
5
|
+
// Atelier never imports the optional ORM or reconstructs it around a driver tx.
|
|
6
|
+
function bridge(database, sqlTag) {
|
|
7
|
+
if(typeof database?.transaction!=='function'||typeof sqlTag!=='function')throw new ExtensionError('invalid-input')
|
|
8
|
+
const transactions=new WeakMap()
|
|
9
|
+
return { transactions, database:{begin:callback=>database.transaction(tx=>{
|
|
10
|
+
const query=(chunks,...values)=>tx.execute(sqlTag(chunks,...values))
|
|
11
|
+
query.json=value=>JSON.stringify(value)
|
|
12
|
+
transactions.set(query,tx)
|
|
13
|
+
return callback(query)
|
|
14
|
+
})} }
|
|
15
|
+
}
|
|
16
|
+
export async function withDrizzleScope(database, scope, sqlTag, run) {
|
|
17
|
+
if(typeof run!=='function')throw new ExtensionError('invalid-input')
|
|
18
|
+
const native=bridge(database,sqlTag)
|
|
19
|
+
return withPostgresScope(native.database,scope,query=>run(native.transactions.get(query)))
|
|
20
|
+
}
|
|
21
|
+
export async function onceDrizzleOperation(database, scope, input, sqlTag, run) {
|
|
22
|
+
if(typeof run!=='function')throw new ExtensionError('invalid-input')
|
|
23
|
+
const native=bridge(database,sqlTag)
|
|
24
|
+
return oncePostgresOperation(native.database,scope,input,query=>run(native.transactions.get(query)))
|
|
25
|
+
}
|
|
26
|
+
export async function lookupDrizzleOperation(database,scope,sqlTag,operationId) {
|
|
27
|
+
return lookupPostgresOperation(bridge(database,sqlTag).database,scope,operationId)
|
|
28
|
+
}
|
|
29
|
+
export async function drizzleHealth(driver, profile) {
|
|
30
|
+
if (!['drizzle-db-first', 'drizzle-code-first'].includes(profile)) throw new ExtensionError('invalid-input')
|
|
31
|
+
const health = await postgresHealth(driver)
|
|
32
|
+
return { ...health, profile }
|
|
33
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Sql, TransactionSql } from 'postgres'
|
|
2
|
+
import type { Json, ProviderOperationProof } from './contracts.mjs'
|
|
3
|
+
import type { VerifiedScope } from './server.mjs'
|
|
4
|
+
export function withPostgresScope<T>(database: Sql, scope: VerifiedScope, run: (sql: TransactionSql) => Promise<T>): Promise<T>
|
|
5
|
+
export function oncePostgresOperation(database: Sql, scope: VerifiedScope, input: Json, run: (sql: TransactionSql) => Promise<Json>): Promise<Json>
|
|
6
|
+
export function lookupPostgresOperation(database: Sql, scope: VerifiedScope, operationId: string): Promise<ProviderOperationProof|null>
|
|
7
|
+
export function postgresHealth(database: Sql): Promise<{ ready: true; profile: 'postgres-sql'; postgresMajor: 17 }>
|