@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.
Files changed (253) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +102 -52
  3. package/SECURITY.md +1 -1
  4. package/TRADEMARKS.md +2 -2
  5. package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
  6. package/contracts/atelier-graph-view.v1.schema.json +1085 -0
  7. package/contracts/public-api-baseline.json +298 -19
  8. package/docs/README.md +90 -0
  9. package/docs/blocks/capability-stewardship.md +1 -1
  10. package/docs/blocks/claims.md +2 -2
  11. package/docs/blocks/conformance-admission.md +1 -1
  12. package/docs/blocks/learning-harnesses.md +1 -1
  13. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  14. package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
  15. package/docs/guides/co-authoring.md +113 -0
  16. package/docs/{distributions.md → guides/distributions.md} +13 -8
  17. package/docs/guides/extensions.md +189 -0
  18. package/docs/guides/graph-refresh.md +152 -0
  19. package/docs/guides/graph-workspace.md +64 -0
  20. package/docs/{ingestion.md → guides/ingestion.md} +16 -15
  21. package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
  22. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  23. package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
  24. package/docs/{learning.md → guides/learning.md} +12 -5
  25. package/docs/{obsidian.md → guides/obsidian.md} +370 -23
  26. package/docs/guides/ontology-guide.md +111 -0
  27. package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
  28. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  29. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  30. package/docs/{upgrade.md → guides/upgrade.md} +197 -11
  31. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  32. package/docs/knowledge-health/facilitator.md +15 -4
  33. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  34. package/docs/knowledge-health/quickstart.md +97 -75
  35. package/docs/knowledge-health/readme.md +13 -5
  36. package/docs/maintainers/describing-the-atelier.md +173 -0
  37. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
  38. package/docs/{announcements.md → reference/announcements.md} +1 -1
  39. package/docs/{architecture.md → reference/architecture.md} +31 -5
  40. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  41. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  42. package/docs/{coordination.md → reference/coordination.md} +1 -1
  43. package/docs/{decisions.md → reference/decisions.md} +3 -3
  44. package/docs/{design.md → reference/design.md} +1 -1
  45. package/docs/reference/edge-policy.md +151 -0
  46. package/docs/reference/graph-view-structured.md +119 -0
  47. package/docs/reference/graph-view.md +139 -0
  48. package/docs/reference/graph-workspace.md +37 -0
  49. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  50. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  51. package/docs/{local-services.md → reference/local-services.md} +43 -4
  52. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
  53. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
  54. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  55. package/docs/reference/portability.md +92 -0
  56. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  57. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  58. package/docs/{project-options.md → reference/project-options.md} +17 -0
  59. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
  60. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  61. package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
  62. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  63. package/docs/{trackables.md → reference/trackables.md} +2 -2
  64. package/docs/start/independent-start.md +89 -0
  65. package/docs/{install.md → start/install.md} +9 -9
  66. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  67. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  68. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  69. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  70. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  71. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  72. package/fixtures/knowledge-health/workshop/README.md +1 -1
  73. package/fixtures/templates/local-library.v1.json +3 -1
  74. package/package.json +22 -8
  75. package/plugins/obsidian/main.js +57 -8
  76. package/plugins/obsidian/manifest.json +1 -1
  77. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  78. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  79. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  80. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  81. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  82. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  83. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  84. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  85. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  86. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  87. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  88. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  89. package/src/architecture/catalog.mjs +2 -2
  90. package/src/attestation/sign.mjs +1 -1
  91. package/src/boundary/content-rules.mjs +534 -68
  92. package/src/boundary/policy.mjs +187 -60
  93. package/src/cli/run.mjs +26 -9
  94. package/src/commands/distribution.mjs +1 -1
  95. package/src/commands/extension.mjs +27 -0
  96. package/src/commands/ingest.mjs +1 -1
  97. package/src/commands/learn.mjs +1 -1
  98. package/src/commands/obsidian.mjs +49 -21
  99. package/src/commands/review.mjs +3 -0
  100. package/src/edge-policy/contract.mjs +69 -0
  101. package/src/edge-policy/policy.mjs +142 -0
  102. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  103. package/src/extension-packs/loader.mjs +4 -3
  104. package/src/extensions/admission.mjs +83 -0
  105. package/src/extensions/client.d.mts +23 -0
  106. package/src/extensions/client.mjs +99 -0
  107. package/src/extensions/contracts.d.mts +29 -0
  108. package/src/extensions/contracts.mjs +163 -0
  109. package/src/extensions/convex.d.mts +24 -0
  110. package/src/extensions/convex.mjs +189 -0
  111. package/src/extensions/delegation.mjs +54 -0
  112. package/src/extensions/drizzle.d.mts +11 -0
  113. package/src/extensions/drizzle.mjs +33 -0
  114. package/src/extensions/postgres.d.mts +7 -0
  115. package/src/extensions/postgres.mjs +66 -0
  116. package/src/extensions/prisma.d.mts +13 -0
  117. package/src/extensions/prisma.mjs +63 -0
  118. package/src/extensions/project.mjs +432 -0
  119. package/src/extensions/scaffold-convex.mjs +130 -0
  120. package/src/extensions/scaffold-sql.mjs +144 -0
  121. package/src/extensions/server.d.mts +20 -0
  122. package/src/extensions/server.mjs +102 -0
  123. package/src/extensions/sql-scope.mjs +22 -0
  124. package/src/graph/graph.mjs +13 -5
  125. package/src/graph/knowledge-graph.mjs +20 -2
  126. package/src/graph/repo-contribution.mjs +405 -0
  127. package/src/graph/repo-contribution.types.mjs +32 -0
  128. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  129. package/src/graph-access/edge-record-adapter.mjs +73 -0
  130. package/src/graph-access/index.mjs +253 -0
  131. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  132. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  133. package/src/graph-access/source-adapter.mjs +81 -0
  134. package/src/graph-refresh/compare.mjs +110 -0
  135. package/src/graph-refresh/controller.mjs +324 -0
  136. package/src/graph-refresh/index.mjs +3 -0
  137. package/src/graph-refresh/local.mjs +78 -0
  138. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  139. package/src/graph-view/babylon/index.mjs +2 -0
  140. package/src/graph-view/babylon/navigator.mjs +312 -0
  141. package/src/graph-view/babylon/provenance.mjs +36 -0
  142. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  143. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  144. package/src/graph-view/babylon/web-host.mjs +188 -0
  145. package/src/graph-view/cli.mjs +3 -0
  146. package/src/graph-view/command.mjs +34 -0
  147. package/src/graph-view/contracts.mjs +79 -0
  148. package/src/graph-view/errors.mjs +5 -0
  149. package/src/graph-view/index.mjs +4 -0
  150. package/src/graph-view/intents.mjs +30 -0
  151. package/src/graph-view/query.mjs +213 -0
  152. package/src/graph-view/refs.mjs +2 -0
  153. package/src/graph-view/structured/browser.mjs +262 -0
  154. package/src/graph-view/structured/styles.mjs +8 -0
  155. package/src/graph-workspace/authoring.mjs +185 -0
  156. package/src/graph-workspace/browser/constellation.mjs +369 -0
  157. package/src/graph-workspace/browser/dom.mjs +23 -0
  158. package/src/graph-workspace/browser/index.mjs +6 -0
  159. package/src/graph-workspace/browser/review.mjs +55 -0
  160. package/src/graph-workspace/browser/styles.mjs +11 -0
  161. package/src/graph-workspace/browser/workbench.mjs +351 -0
  162. package/src/graph-workspace/browser/workspace.mjs +431 -0
  163. package/src/graph-workspace/disclosure.mjs +39 -0
  164. package/src/graph-workspace/geometry.mjs +78 -0
  165. package/src/graph-workspace/index.mjs +11 -0
  166. package/src/graph-workspace/labels.mjs +50 -0
  167. package/src/graph-workspace/model.mjs +109 -0
  168. package/src/graph-workspace/recipes.mjs +88 -0
  169. package/src/graph-workspace/scale.mjs +95 -0
  170. package/src/graph-workspace/theme.mjs +156 -0
  171. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  172. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  173. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  174. package/src/knowledge/ontology-guide.mjs +152 -0
  175. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  176. package/src/knowledge-health/rule-identity.mjs +14 -9
  177. package/src/knowledge-health/workshop.mjs +22 -2
  178. package/src/personal-workspace/index.mjs +1 -1
  179. package/src/personal-workspace/selection.mjs +34 -3
  180. package/src/project/config.mjs +1 -1
  181. package/src/project/path-match.mjs +2 -1
  182. package/src/project/providers/github.mjs +24 -0
  183. package/src/project/repo-identity.mjs +3 -22
  184. package/src/projection/obsidian/contracts.mjs +1 -1
  185. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  186. package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
  187. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  188. package/src/projection/obsidian/personal-workspace.mjs +84 -5
  189. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  190. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  191. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  192. package/src/projection/obsidian/publication/publisher.mjs +96 -8
  193. package/src/projection/project.mjs +21 -16
  194. package/src/readiness/readiness.mjs +20 -11
  195. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  196. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  197. package/src/readiness-protocols/evidence.mjs +2 -1
  198. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  199. package/src/readiness-protocols/runtime.mjs +43 -6
  200. package/src/runtime/obsidian/engine.mjs +387 -76
  201. package/src/runtime/obsidian/lifecycle.mjs +20 -15
  202. package/src/runtime/obsidian/login-item.mjs +6 -3
  203. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  204. package/src/runtime/obsidian/observation.mjs +36 -4
  205. package/src/runtime/obsidian/opening.mjs +4 -3
  206. package/src/runtime/obsidian/personal-selection.mjs +187 -0
  207. package/src/runtime/obsidian/pipeline.mjs +115 -6
  208. package/src/runtime/obsidian/private-lock.mjs +50 -6
  209. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  210. package/src/runtime/obsidian/service-main.mjs +68 -16
  211. package/src/runtime/obsidian/service.mjs +80 -6
  212. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  213. package/src/runtime/obsidian/view-permission.mjs +335 -0
  214. package/src/ui/control-system.mjs +201 -0
  215. package/src/ui/html-primitives.mjs +3 -0
  216. package/src/upgrade/closure-diagnostics.mjs +135 -0
  217. package/src/upgrade/closure.mjs +649 -0
  218. package/src/upgrade/upgrade.mjs +42 -11
  219. package/templates/distribution-workspace/README.md +2 -2
  220. package/templates/external-project-workspace/atelier.project.json +3 -0
  221. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  222. package/templates/knowledge-workspace/README.md +2 -2
  223. package/docs/integration-and-review-plan.md +0 -409
  224. package/docs/integration-contract-decisions.md +0 -44
  225. package/docs/knowledge-health/devday/facilitator.md +0 -16
  226. package/docs/knowledge-health/devday/public-default.md +0 -107
  227. package/docs/knowledge-health/devday/quickstart.md +0 -18
  228. package/docs/knowledge-health/devday/support.md +0 -22
  229. package/docs/knowledge-health/exercise.md +0 -17
  230. package/docs/knowledge-health/support.md +0 -34
  231. package/docs/presentation-review-disposition.md +0 -47
  232. package/docs/progressive-ingestion-acceptance.md +0 -89
  233. package/docs/progressive-ingestion.md +0 -235
  234. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  235. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  236. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  237. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  238. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  239. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  240. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  241. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  242. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  243. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  244. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  245. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  246. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  247. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  248. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  249. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  250. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  251. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  252. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  253. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -0,0 +1,130 @@
1
+ // Source templates only. The CLI never starts a provider or provisions an account.
2
+ export function convexProjectFiles() {
3
+ return {
4
+ dependencies: { convex: '1.46.0' },
5
+ files: {
6
+ 'convex.json': JSON.stringify({ functions: 'convex/' }, null, 2) + '\n',
7
+ 'src/client.ts': "export { createExtensionClient } from '@mnstry/atelier/extensions/client'\n",
8
+ 'convex/schema.ts': "export { default } from '../migrations/001-schema'\n",
9
+ 'migrations/001-schema.ts': `import { defineSchema, defineTable } from 'convex/server'
10
+ import { v } from 'convex/values'
11
+ const scope = { installationId: v.string(), tenantRef: v.string(), subjectRef: v.string() }
12
+ export default defineSchema({
13
+ extensionCounters: defineTable({ ...scope, value: v.number() }).index('by_scope', ['installationId', 'tenantRef', 'subjectRef']),
14
+ extensionOperations: defineTable({ ...scope, operationId: v.string(), actionId: v.string(), generation: v.string(),
15
+ releaseDigest: v.string(), payloadDigest: v.string(), result: v.any(), resultDigest: v.string() })
16
+ .index('by_scope_operation', ['installationId', 'tenantRef', 'subjectRef', 'operationId']),
17
+ })
18
+ `,
19
+ 'convex/backend.ts': `import { internalMutation, internalQuery } from './_generated/server'
20
+ import type { DataModel } from './_generated/dataModel'
21
+ import type { MutationCtx } from './_generated/server'
22
+ import { v } from 'convex/values'
23
+ import manifest from '../extension.manifest.json'
24
+ import { canonicalJson, exact, ExtensionError, parseManifest, parseProviderOperationProof, type Json } from '@mnstry/atelier/extensions'
25
+ import { defineConvexExtension, runConvexInvocation, onceConvexOperation, lookupConvexOperation, convexHealth,
26
+ type ConvexVerifierOptions } from '@mnstry/atelier/extensions/convex'
27
+ import type { VerifiedScope } from '@mnstry/atelier/extensions/server'
28
+ const counter = (value: unknown) => {
29
+ const input = exact(value, ['value'])
30
+ if (!Number.isSafeInteger(input.value) || Number(input.value) < 0 || Number(input.value) > 1000000) throw new ExtensionError('invalid-input')
31
+ return { value: Number(input.value) }
32
+ }
33
+ const empty = (value: unknown) => { exact(value, []); return {} }
34
+ const json = (value: unknown): Json => JSON.parse(canonicalJson(value))
35
+ const scoped = (context: MutationCtx, scope: VerifiedScope) => context.db.query('extensionCounters').withIndex('by_scope',
36
+ query => query.eq('installationId', scope.installationId).eq('tenantRef', scope.tenantRef).eq('subjectRef', scope.subjectRef))
37
+ export function extension(consume?: ConvexVerifierOptions['consume']) {
38
+ const keys: Record<string, JsonWebKey> = JSON.parse(process.env.EXTENSION_ROOT_PUBLIC_KEYS ?? '{}')
39
+ return defineConvexExtension<DataModel>({ manifest: parseManifest(manifest), verifier: {
40
+ issuer: process.env.EXTENSION_ROOT_ISSUER ?? '', audience: process.env.EXTENSION_AUDIENCE ?? '',
41
+ installationId: process.env.EXTENSION_INSTALLATION_ID ?? '', releaseDigest: manifest.releaseDigest,
42
+ keys: new Map(Object.entries(keys)), consume,
43
+ }, actions: {
44
+ 'counter.set': { input: counter, output: counter, run: ({ context, scope }, input) => onceConvexOperation(context, scope, input, async () => {
45
+ const row = await scoped(context, scope).unique()
46
+ if (row) await context.db.patch(row._id, input)
47
+ else await context.db.insert('extensionCounters', { installationId: scope.installationId, tenantRef: scope.tenantRef, subjectRef: scope.subjectRef, ...input })
48
+ return input
49
+ }) },
50
+ 'counter.get': { input: empty, output: counter, run: async ({ context, scope }) => ({ value: (await scoped(context, scope).unique())?.value ?? 0 }) },
51
+ 'counter.export': { input: empty, output: json, run: async ({ context, scope }) => ({ schema: 'sample.counter-export/v1', value: (await scoped(context, scope).unique())?.value ?? 0 }) },
52
+ 'counter.delete': { input: empty, output: json, run: ({ context, scope }, input) => onceConvexOperation(context, scope, input, async () => {
53
+ const rows = await context.db.query('extensionOperations').withIndex('by_scope_operation',
54
+ query => query.eq('installationId', scope.installationId).eq('tenantRef', scope.tenantRef).eq('subjectRef', scope.subjectRef)).take(501)
55
+ // A larger history needs an explicitly designed paginated erasure workflow.
56
+ // Refuse atomically instead of claiming a partial delete is complete.
57
+ if (rows.length > 500) throw new ExtensionError('unavailable', 503)
58
+ const row = await scoped(context, scope).unique()
59
+ if (row) await context.db.delete(row._id)
60
+ for (const operation of rows) await context.db.delete(operation._id)
61
+ return { deleted: row ? 1 : 0, retention: 'provider-live-rows-deleted', backups: 'operator-policy' }
62
+ }) },
63
+ 'counter.lookup': { input: value => {
64
+ const input = exact(value, ['operationId'])
65
+ if (typeof input.operationId !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(input.operationId)) throw new ExtensionError('invalid-input')
66
+ return { operationId: input.operationId }
67
+ }, output: value => value === null ? null : parseProviderOperationProof(value),
68
+ run: ({ context, scope }, input) => lookupConvexOperation(context, scope, input.operationId) },
69
+ } })
70
+ }
71
+ export const health = internalQuery({ args: {}, handler: context => convexHealth(context, parseManifest(manifest)) })
72
+ // This is intentionally internal. No public mutation/query exposes member tables.
73
+ export const invoke = internalMutation({ args: { delegation: v.string(), payload: v.any() },
74
+ handler: (context, args) => runConvexInvocation(extension(), context, { delegation: args.delegation, payload: json(args.payload) }) })
75
+ `,
76
+ 'convex/http.ts': `import { httpRouter } from 'convex/server'
77
+ import { httpAction } from './_generated/server'
78
+ import { internal } from './_generated/api'
79
+ import { createConvexHttpHandler, createRootAdmissionConsumer } from '@mnstry/atelier/extensions/convex'
80
+ import { extension } from './backend'
81
+ const http = httpRouter()
82
+ http.route({ path: '/health', method: 'GET', handler: httpAction(async context => {
83
+ try { return Response.json(await context.runQuery(internal.backend.health, {}), { headers: { 'cache-control': 'no-store' } }) }
84
+ catch { return Response.json({ code: 'unavailable' }, { status: 503, headers: { 'cache-control': 'no-store' } }) }
85
+ }) })
86
+ http.route({ path: '/invoke', method: 'POST', handler: httpAction((context, request) => {
87
+ const consume: ReturnType<typeof createRootAdmissionConsumer> = (claims, delegation) => createRootAdmissionConsumer({
88
+ origin: process.env.EXTENSION_ROOT_ORIGIN ?? '', allowLocalTesting: process.env.EXTENSION_ALLOW_LOCAL_TESTING === 'true' })(claims, delegation)
89
+ return createConvexHttpHandler(extension(consume), args => context.runMutation(internal.backend.invoke, args))(request)
90
+ }) })
91
+ export default http
92
+ `,
93
+ 'convex/tsconfig.json': JSON.stringify({ compilerOptions: { strict: true, target: 'ES2022', lib: ['ES2022', 'DOM'],
94
+ module: 'ESNext', moduleResolution: 'Bundler', resolveJsonModule: true, allowJs: true, noEmit: true, skipLibCheck: true, types: ['node'] }, include: ['./**/*'], exclude: ['./_generated'] }, null, 2) + '\n',
95
+ 'env.example': '# Set only on the customer-owned Convex deployment; public verification keys only.\nEXTENSION_ROOT_ORIGIN=\nEXTENSION_ROOT_ISSUER=\nEXTENSION_ROOT_PUBLIC_KEYS=\nEXTENSION_AUDIENCE=\nEXTENSION_INSTALLATION_ID=\n',
96
+ 'README.md': `# Native counter extension
97
+
98
+ This source-only scaffold uses Convex 1.46.0 and its native schema, indexes,
99
+ internal mutations, OCC transactions and HTTP actions. It installs no SQL driver.
100
+ Use your own Convex project and reviewed deployment workflow. Atelier init never
101
+ starts a backend, logs into an account, deploys code or grants an installation.
102
+
103
+ Install the exact Atelier candidate tarball and pinned dependencies. The versioned native schema lives in migrations/001-schema.ts; convex/schema.ts
104
+ re-exports it. Its bytes are bound by the existing schema/migration digest.
105
+ Run Convex code generation before the final seal; its generated files are sealed too.
106
+ Run the project typecheck, then atelier extension seal, validate and bundle.
107
+ After deployment, validate again and bind the tested deployment identity to the
108
+ release before host registration. Generated files changing makes the seal stale.
109
+ Set the public verification keys, Root issuer/admission origin, exact backend
110
+ invocation audience and opaque installation reference in deployment environment.
111
+ No admin key, Root database credential or member identity crosses this boundary.
112
+
113
+ POST /invoke verifies the original short-lived delegation, consumes Root admission
114
+ once, then calls the private internal mutation with the original token and payload.
115
+ The mutation verifies them again. Native transactions commit custom writes and the
116
+ indexed operation result atomically. A lost HTTP reply needs a new admitted lookup
117
+ operation; never repeat a business write just because its reply was lost.
118
+
119
+ GET /health reports the existing immutable release/schema health envelope after\nnative index checks, without member data. Output validators must preserve the\ncanonical result; construct that result inside the action before its once helper.\n\nThe sample supports scoped set/get/export/delete/lookup. Delete clears this member's
120
+ live sample rows and older operation results atomically, up to 500 operations; a
121
+ larger history refuses and requires a paginated erasure design. Backup retention
122
+ remains the operator's responsibility. Existing admitted effects may finish after
123
+ disable. No direct public query or subscription exposes member tables. Reactive
124
+ revocation, migrations/backfills, comprehensive privacy lifecycle and complete host
125
+ journeys remain separate qualification work. Do not enable a host profile on the
126
+ strength of a scaffold or unit test.
127
+ `,
128
+ },
129
+ }
130
+ }
@@ -0,0 +1,144 @@
1
+ // Generated projects retain the native ORM. These strings never run in Atelier.
2
+ const drizzleSchema = `import { sql } from 'drizzle-orm'
3
+ import { bigint, boolean, check, integer, jsonb, pgSchema, primaryKey, text } from 'drizzle-orm/pg-core'
4
+ export const extensionData=pgSchema('extension_data')
5
+ const scope = () => ({ installationId:text('installation_id').notNull(),tenantRef:text('tenant_ref').notNull(),subjectRef:text('subject_ref').notNull() })
6
+ export const countersInExtensionData=extensionData.table('counters',{...scope(),value:integer('value').notNull()},table=>[
7
+ primaryKey({columns:[table.installationId,table.tenantRef,table.subjectRef]}),check('counter_value',sql\`\${table.value} BETWEEN 0 AND 1000000\`),
8
+ ])
9
+ export const operationsInExtensionData=extensionData.table('operations',{...scope(),operationId:text('operation_id').notNull(),actionId:text('action_id').notNull(),
10
+ generation:bigint('generation',{mode:'bigint'}).notNull(),releaseDigest:text('release_digest').notNull(),payloadDigest:text('payload_digest').notNull(),
11
+ completed:boolean('completed').notNull().default(false),result:jsonb('result')},table=>[
12
+ primaryKey({columns:[table.installationId,table.tenantRef,table.subjectRef,table.operationId]}),
13
+ check('operation_generation',sql\`\${table.generation}>0\`),check('release_digest_format',sql\`\${table.releaseDigest} ~ '^[a-f0-9]{64}$'\`),
14
+ check('payload_digest_format',sql\`\${table.payloadDigest} ~ '^[a-f0-9]{64}$'\`),
15
+ ])
16
+ `
17
+ const prismaSchema = `generator client {
18
+ provider = "prisma-client"
19
+ output = "../src/generated/prisma"
20
+ moduleFormat = "esm"
21
+ importFileExtension = "js"
22
+ }
23
+ datasource db {
24
+ provider = "postgresql"
25
+ schemas = ["extension_data"]
26
+ }
27
+ model Counter {
28
+ installationId String @map("installation_id")
29
+ tenantRef String @map("tenant_ref")
30
+ subjectRef String @map("subject_ref")
31
+ value Int
32
+ @@id([installationId,tenantRef,subjectRef])
33
+ @@map("counters")
34
+ @@schema("extension_data")
35
+ }
36
+ model Operation {
37
+ installationId String @map("installation_id")
38
+ tenantRef String @map("tenant_ref")
39
+ subjectRef String @map("subject_ref")
40
+ operationId String @map("operation_id")
41
+ actionId String @map("action_id")
42
+ generation BigInt
43
+ releaseDigest String @map("release_digest")
44
+ payloadDigest String @map("payload_digest")
45
+ completed Boolean @default(false)
46
+ result Json?
47
+ @@id([installationId,tenantRef,subjectRef,operationId])
48
+ @@map("operations")
49
+ @@schema("extension_data")
50
+ }
51
+ `
52
+ function replace(source, before, after) {
53
+ if(source.split(before).length!==2) throw new Error('scaffold source shape changed')
54
+ return source.replace(before,after)
55
+ }
56
+ export function sqlProjectFiles(profile, baseBackend, baseMigration) {
57
+ const drizzle=profile==='drizzle-db-first'||profile==='drizzle-code-first'
58
+ if(!drizzle && profile!=='prisma-7-postgres')throw new Error('unsupported SQL profile')
59
+ let backend=baseBackend
60
+ const names=drizzle?['Drizzle','drizzle']:['Prisma','prisma']
61
+ backend=backend.replaceAll('oncePostgresOperation',`once${names[0]}Operation`).replaceAll('lookupPostgresOperation',`lookup${names[0]}Operation`)
62
+ .replaceAll('withPostgresScope',`with${names[0]}Scope`).replaceAll('postgresHealth',`${names[1]}Health`)
63
+ .replace("from '@mnstry/atelier/extensions/postgres'",`from '@mnstry/atelier/extensions/${names[1]}'`)
64
+ backend=replace(backend,"import type { Sql } from 'postgres'",drizzle?`import type { Sql } from 'postgres'
65
+ import { and, eq, sql as query } from 'drizzle-orm'
66
+ import { drizzle } from 'drizzle-orm/postgres-js'
67
+ import { countersInExtensionData as counters } from './${profile==='drizzle-db-first'?'introspected/':''}schema.js'
68
+ import type { VerifiedScope } from '@mnstry/atelier/extensions/server'
69
+ const scopedWhere=(scope:VerifiedScope)=>and(eq(counters.installationId,scope.installationId),eq(counters.tenantRef,scope.tenantRef),eq(counters.subjectRef,scope.subjectRef))`:
70
+ `import type { PrismaClient } from './generated/prisma/client.js'`)
71
+ if(drizzle) {
72
+ backend=backend.replaceAll('database, scope, input, async sql =>','database, scope, input, query, async db =>')
73
+ .replaceAll('database, scope, async sql =>','database, scope, query, async db =>')
74
+ backend=backend.replace('const database = options.database','const database = drizzle(options.database,{schema:{counters}})').replace('lookupDrizzleOperation(database, scope, input.operationId)','lookupDrizzleOperation(database, scope, query, input.operationId)').replace('drizzleHealth(database)',`drizzleHealth(options.database,'${profile}')`)
75
+ }else backend=backend.replace('database: Sql','database: PrismaClient').replaceAll('database, scope, input, async sql =>','database, scope, input, async db =>').replaceAll('database, scope, async sql =>','database, scope, async db =>')
76
+ backend=replace(backend,`await sql\`INSERT INTO extension_data.counters (installation_id,tenant_ref,subject_ref,value)
77
+ VALUES (\${scope.installationId},\${scope.tenantRef},\${scope.subjectRef},\${input.value})
78
+ ON CONFLICT (installation_id,tenant_ref,subject_ref) DO UPDATE SET value=excluded.value\``,drizzle?
79
+ `await db.insert(counters).values({installationId:scope.installationId,tenantRef:scope.tenantRef,subjectRef:scope.subjectRef,value:input.value})
80
+ .onConflictDoUpdate({target:[counters.installationId,counters.tenantRef,counters.subjectRef],set:{value:input.value}})`:
81
+ `await db.counter.upsert({where:{installationId_tenantRef_subjectRef:{installationId:scope.installationId,tenantRef:scope.tenantRef,subjectRef:scope.subjectRef}},
82
+ create:{installationId:scope.installationId,tenantRef:scope.tenantRef,subjectRef:scope.subjectRef,value:input.value},update:{value:input.value}})`)
83
+ const read=`const [row] = await sql\`SELECT value FROM extension_data.counters WHERE installation_id=\${scope.installationId}
84
+ AND tenant_ref=\${scope.tenantRef} AND subject_ref=\${scope.subjectRef}\``
85
+ if(backend.split(read).length!==3)throw new Error('scaffold read shape changed')
86
+ backend=backend.replaceAll(read,drizzle?`const [row]=await db.select({value:counters.value}).from(counters).where(scopedWhere(scope)).limit(1)`:
87
+ `const row=await db.counter.findUnique({where:{installationId_tenantRef_subjectRef:{installationId:scope.installationId,tenantRef:scope.tenantRef,subjectRef:scope.subjectRef}}})`)
88
+ backend=replace(backend,`const rows = await sql\`DELETE FROM extension_data.counters WHERE installation_id=\${scope.installationId}
89
+ AND tenant_ref=\${scope.tenantRef} AND subject_ref=\${scope.subjectRef} RETURNING value\``,drizzle?
90
+ `const rows=await db.delete(counters).where(scopedWhere(scope)).returning({value:counters.value})`:
91
+ `const deleted=await db.counter.deleteMany({where:{installationId:scope.installationId,tenantRef:scope.tenantRef,subjectRef:scope.subjectRef}})
92
+ const rows=Array.from({length:deleted.count})`)
93
+ backend=replace(backend,`await sql\`DELETE FROM extension_data.operations WHERE installation_id=\${scope.installationId}
94
+ AND tenant_ref=\${scope.tenantRef} AND subject_ref=\${scope.subjectRef} AND operation_id<>\${scope.operationId}\``,drizzle?
95
+ `await db.execute(query\`DELETE FROM extension_data.operations WHERE installation_id=\${scope.installationId}
96
+ AND tenant_ref=\${scope.tenantRef} AND subject_ref=\${scope.subjectRef} AND operation_id<>\${scope.operationId}\`)`:
97
+ `await db.$executeRaw\`DELETE FROM extension_data.operations WHERE installation_id=\${scope.installationId}
98
+ AND tenant_ref=\${scope.tenantRef} AND subject_ref=\${scope.subjectRef} AND operation_id<>\${scope.operationId}\``)
99
+ const files={'src/backend.ts':backend}
100
+ const dependencies=drizzle?{'drizzle-orm':'0.44.5',postgres:'3.4.7'}:{'@prisma/client':'7.10.0','@prisma/adapter-pg':'7.10.0',pg:'8.16.3'}
101
+ const devDependencies=drizzle?{'drizzle-kit':'0.31.8'}:{prisma:'7.10.0','@types/pg':'8.15.5'}
102
+ const scripts=drizzle?{'schema:generate':'drizzle-kit generate --config drizzle.config.ts','schema:introspect':'drizzle-kit pull --config drizzle-introspect.config.ts && node src/normalize-introspection.mjs'}:
103
+ {'schema:generate':'prisma generate','schema:introspect':'prisma db pull'}
104
+ if(drizzle){
105
+ // Drizzle Kit 0.31 emits an extensionless local relation import. Preserve
106
+ // the native generated schema and normalize only that Node ESM specifier.
107
+ files['src/normalize-introspection.mjs']=`import {lstat,readFile,rename,rm,writeFile} from 'node:fs/promises'
108
+ import {randomUUID} from 'node:crypto'
109
+ const file=new URL('./introspected/relations.ts',import.meta.url)
110
+ const identity=await lstat(file)
111
+ if(!identity.isFile()||identity.isSymbolicLink())throw new Error('Generated relations must be a regular file')
112
+ const before=await readFile(file,'utf8')
113
+ const after=before.replaceAll('from "./schema"','from "./schema.js"').replaceAll("from './schema'","from './schema.js'")
114
+ if(after!==before){
115
+ const temporary=new URL('./introspected/.relations-'+randomUUID()+'.tmp',import.meta.url)
116
+ try {
117
+ await writeFile(temporary,after,{flag:'wx'})
118
+ const current=await lstat(file)
119
+ if(!current.isFile()||current.isSymbolicLink()||current.dev!==identity.dev||current.ino!==identity.ino||await readFile(file,'utf8')!==before)throw new Error('Generated relations changed concurrently')
120
+ await rename(temporary,file)
121
+ } finally { await rm(temporary,{force:true}) }
122
+ }
123
+ `
124
+ files[`src/${profile==='drizzle-db-first'?'introspected/':''}schema.ts`]=drizzleSchema
125
+ files['drizzle.config.ts']=`import {defineConfig} from 'drizzle-kit'\nexport default defineConfig({dialect:'postgresql',schema:'./src/${profile==='drizzle-db-first'?'introspected/':''}schema.ts',out:'./migrations/drizzle',schemaFilter:['extension_data'],strict:true,verbose:false})\n`
126
+ files['drizzle-introspect.config.ts']=`import {defineConfig} from 'drizzle-kit'\nconst url=process.env.EXTENSION_MIGRATION_DATABASE_URL\nif(!url)throw new Error('Migration database secret must be supplied explicitly')\nexport default defineConfig({dialect:'postgresql',out:'./src/introspected',schemaFilter:['extension_data'],dbCredentials:{url},strict:true,verbose:false})\n`
127
+ if(profile==='drizzle-code-first') {
128
+ // The native generator creates tables. These reviewed policy statements must follow it.
129
+ files['migrations/001-initial.sql']=`-- Apply the native Drizzle-generated SQL first, then migrations/002-governance.sql.\n`
130
+ const governance=baseMigration.split('\n').filter(line=>/^(REVOKE|ALTER TABLE|CREATE POLICY| USING| AND subject_ref| WITH CHECK|GRANT)/.test(line))
131
+ files['migrations/002-governance.sql']=governance.join('\n')+'\n'
132
+ }
133
+ } else {
134
+ files['prisma/schema.prisma']=prismaSchema
135
+ files['prisma.config.ts']=`import {defineConfig} from 'prisma/config'\nconst url=process.env.EXTENSION_MIGRATION_DATABASE_URL\nexport default defineConfig({schema:'prisma/schema.prisma',...(url?{datasource:{url}}:{})})\n`
136
+ files['src/database.ts']=`import {PrismaPg} from '@prisma/adapter-pg'\nimport {PrismaClient} from './generated/prisma/client.js'\nexport function createDatabase(connectionString:string){\n if(!connectionString)throw new Error('Application database secret must be supplied explicitly')\n return new PrismaClient({adapter:new PrismaPg({connectionString,max:6,connectionTimeoutMillis:5000,idleTimeoutMillis:1000},{schema:'extension_data'})})\n}\n`
137
+ }
138
+ files['env.example']='# Keep both secrets outside source; use separate restricted application and migration roles.\nEXTENSION_DATABASE_URL=\nEXTENSION_MIGRATION_DATABASE_URL=\n'
139
+ const instructions=drizzle?(profile==='drizzle-code-first'?
140
+ 'This profile uses native Drizzle code-first generation. After npm install, run npm run schema:generate, review its SQL under migrations/drizzle, apply it with the separate migration role, then apply migrations/002-governance.sql. Omitting forced RLS makes health refuse. Reseal after generator output changes. Later generation is a proposed migration; review it before application.':
141
+ 'This profile uses database-first ownership. Apply the reviewed baseline migration with the migration role, then run npm run schema:introspect against only extension_data. Review src/introspected/schema.ts and retain it in source; it replaces the initial example projection. The backend consumes that actual generated schema. Reseal after introspection. Introspection does not prove RLS policy semantics.'):
142
+ 'This profile uses Prisma 7 with its native pg adapter and generated client. After npm install, run npm run schema:generate. Apply migrations/001-initial.sql with the separate migration role. Prisma schema/client generation does not install RLS; the reviewed SQL is required. Use src/database.ts from your own host with an explicit application secret. Reseal after schema/client changes. Prisma 8 remains an unqualified separate experiment.'
143
+ return {files,dependencies,devDependencies,scripts,instructions}
144
+ }
@@ -0,0 +1,20 @@
1
+ import type { KeyObject } from 'node:crypto'
2
+ import type { ExtensionManifest, Json } from './contracts.mjs'
3
+ export interface VerifiedScope {
4
+ readonly installationId: string; readonly tenantRef: string; readonly subjectRef: string; readonly generation: string;
5
+ readonly releaseDigest: string; readonly actionId: string; readonly operationId: string; readonly payloadDigest: string; readonly rootWrites: readonly string[];
6
+ }
7
+ export interface VerifierOptions {
8
+ issuer: string; audience: string; installationId: string; releaseDigest: string; keys: ReadonlyMap<string, KeyObject>;
9
+ now?: () => number; consume(claims: Readonly<Record<string, unknown>>, delegation: string): Promise<boolean>;
10
+ }
11
+ export interface ActionDefinition<I = Json, O = Json> {
12
+ input(value: unknown): I; output(value: unknown): O; run(context: { scope: VerifiedScope; signal: AbortSignal }, input: I): unknown | Promise<unknown>;
13
+ }
14
+ export function digest(value: unknown): string
15
+ export function releaseDigest(value: unknown): string
16
+ export function assertVerifiedScope(scope: VerifiedScope): void
17
+ export function createRootAdmissionConsumer(options: { origin: string; allowLocalTesting?: boolean; fetch?: typeof fetch }): VerifierOptions['consume']
18
+ export function defineAction<I, O>(definition: ActionDefinition<I, O>): Readonly<ActionDefinition<I, O>>
19
+ export function defineExtension(options: { manifest: ExtensionManifest; verifier: VerifierOptions; actions: Record<string, ActionDefinition<any, any>> }): unknown
20
+ export function createExtensionHandler(extension: ReturnType<typeof defineExtension>): (request: Request) => Promise<Response>
@@ -0,0 +1,102 @@
1
+ import { createHash, verify } from 'node:crypto'
2
+ import { canonicalJson, ExtensionError, parseManifest } from './contracts.mjs'
3
+ import { assertDelegationBinding, decodeDelegation, unauthorized, validateDelegationClaims } from './delegation.mjs'
4
+ export { createRootAdmissionConsumer } from './admission.mjs'
5
+
6
+ export const digest = value => createHash('sha256').update(canonicalJson(value)).digest('hex')
7
+ export function releaseDigest(value) {
8
+ const { releaseDigest: omitted, ...rest } = parseManifest(value)
9
+ return digest(rest)
10
+ }
11
+ const admittedScopes = new WeakMap()
12
+ const extensionDefinitions = new WeakMap()
13
+ function freezeJson(value) {
14
+ if (value && typeof value === 'object') { Object.values(value).forEach(freezeJson); Object.freeze(value) }
15
+ return value
16
+ }
17
+ export function assertVerifiedScope(scope) {
18
+ if (!scope || !admittedScopes.get(scope)?.()) throw new ExtensionError('unauthorized', 401)
19
+ }
20
+ function verifiedClaims(delegation, options) {
21
+ try {
22
+ const decoded = decodeDelegation(delegation)
23
+ const key = options.keys.get(decoded.header.kid)
24
+ if (!key || key.type !== 'public' || key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1' ||
25
+ !verify('sha256', decoded.signed, { key, dsaEncoding: 'ieee-p1363' }, decoded.signature)) return unauthorized()
26
+ return validateDelegationClaims(decoded.claims, options)
27
+ } catch { return unauthorized() }
28
+ }
29
+ export function defineAction(definition) {
30
+ if (!definition || ['input', 'output', 'run'].some(key => typeof definition[key] !== 'function')) throw new ExtensionError('invalid-input')
31
+ return Object.freeze({ input: definition.input, output: definition.output, run: definition.run })
32
+ }
33
+ export function defineExtension({ manifest, verifier, actions }) {
34
+ const pinned = parseManifest(manifest)
35
+ if (pinned.releaseDigest !== releaseDigest(pinned) || pinned.actions.some(action => !Object.hasOwn(actions,action.id)) ||
36
+ Object.keys(actions).some(id => !pinned.actions.some(action => action.id === id)) || typeof verifier.consume !== 'function') throw new ExtensionError('invalid-input')
37
+ const runtime = Object.freeze({ manifest: freezeJson(pinned), verifier: Object.freeze({ ...verifier, keys: new Map(verifier.keys) }),
38
+ actions: Object.freeze(Object.fromEntries(Object.entries(actions).map(([id, action]) => [id, defineAction(action)]))) })
39
+ const handle = Object.freeze({ manifest: runtime.manifest })
40
+ extensionDefinitions.set(handle, runtime)
41
+ return handle
42
+ }
43
+ export function createExtensionHandler(handle) {
44
+ const extension = extensionDefinitions.get(handle)
45
+ if (!extension) throw new ExtensionError('invalid-input')
46
+ return async request => {
47
+ let admitted = false
48
+ try {
49
+ if (request.method !== 'POST' || request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return new Response(null, { status: 415 })
50
+ const delegation = request.headers.get('authorization')?.match(/^Bearer ([A-Za-z0-9_.-]+)$/)?.[1]
51
+ if (!delegation) return unauthorized()
52
+ const claims = verifiedClaims(delegation, extension.verifier)
53
+ const manifest = extension.manifest
54
+ assertDelegationBinding(claims, manifest, extension.verifier)
55
+ const declared = manifest.actions.find(action => action.id === claims.actionId)
56
+ const action = Object.hasOwn(extension.actions,claims.actionId) ? extension.actions[claims.actionId] : null
57
+ if (!declared || !action) return unauthorized()
58
+ const body = request.body?.getReader()
59
+ if (!body) throw new ExtensionError('invalid-input')
60
+ let input
61
+ try {
62
+ let size = 0
63
+ const chunks = []
64
+ for (;;) {
65
+ const { value, done } = await body.read()
66
+ if (done) break
67
+ size += value.byteLength
68
+ if (size > 65536) throw new ExtensionError('invalid-input')
69
+ chunks.push(value)
70
+ }
71
+ input = JSON.parse(Buffer.concat(chunks).toString('utf8'))
72
+ } finally { await body.cancel().catch(() => {}) }
73
+ if (digest(input) !== claims.payloadDigest) throw new ExtensionError('invalid-input')
74
+ input = action.input(input)
75
+ if (digest(input) !== claims.payloadDigest) throw new ExtensionError('invalid-input')
76
+ if (!await extension.verifier.consume(claims, delegation)) throw new ExtensionError('forbidden', 403)
77
+ admitted = true
78
+ const scope = Object.freeze({ installationId: claims.installationId, generation: claims.generation, releaseDigest: claims.releaseDigest,
79
+ operationId: claims.operationId, actionId: claims.actionId, payloadDigest: claims.payloadDigest,
80
+ tenantRef: claims.tenantRef, subjectRef: claims.subjectRef, rootWrites: claims.rootWrites })
81
+ const deadline = Math.min(claims.exp * 1000, (extension.verifier.now?.() ?? Date.now()) + declared.timeoutMs)
82
+ const controller = new AbortController()
83
+ admittedScopes.set(scope, () => !controller.signal.aborted && deadline > (extension.verifier.now?.() ?? Date.now()))
84
+ let expire
85
+ const expired = new Promise((_, reject) => { expire = () => { const error = new ExtensionError('operation-unknown', 409); controller.abort(error); reject(error) } })
86
+ const timer = setTimeout(expire, Math.max(0, deadline - (extension.verifier.now?.() ?? Date.now())))
87
+ let result
88
+ try {
89
+ if (deadline <= (extension.verifier.now?.() ?? Date.now())) throw new ExtensionError('operation-unknown', 409)
90
+ result = action.output(await Promise.race([Promise.resolve().then(() => action.run({ scope, signal: controller.signal }, input)), expired]))
91
+ if (controller.signal.aborted || deadline <= (extension.verifier.now?.() ?? Date.now())) throw new ExtensionError('operation-unknown', 409)
92
+ } finally { clearTimeout(timer); admittedScopes.delete(scope) }
93
+ if (Buffer.byteLength(canonicalJson(result)) > 65536) throw new ExtensionError('operation-unknown', 409)
94
+ return Response.json({ operationId: claims.operationId, state: 'succeeded', result, resultDigest: digest(result) }, { headers: { 'cache-control': 'no-store' } })
95
+ } catch (error) {
96
+ const statuses = { 'invalid-input': 400, unauthorized: 401, forbidden: 403, 'operation-unknown': 409, unavailable: 503 }
97
+ const code = admitted ? 'operation-unknown' : error instanceof ExtensionError && Object.hasOwn(statuses, error.code) ? error.code : 'unavailable'
98
+ return Response.json({ code }, { status: statuses[code],
99
+ headers: { 'cache-control': 'no-store' } })
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,22 @@
1
+ import { canonicalJson, ExtensionError, text } from './contracts.mjs'
2
+
3
+ export async function assertApplicationRole(sql) {
4
+ const [role] = await sql`SELECT r.rolsuper, r.rolbypassrls,
5
+ EXISTS (SELECT 1 FROM pg_roles parent WHERE pg_has_role(r.oid,parent.oid,'MEMBER')
6
+ AND (parent.rolsuper OR parent.rolbypassrls)) AS unsafe_membership,
7
+ EXISTS (SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
8
+ WHERE n.nspname='extension_data' AND c.relkind IN ('r','p')
9
+ AND (pg_has_role(r.oid,c.relowner,'MEMBER') OR NOT c.relrowsecurity OR NOT c.relforcerowsecurity)) AS unsafe_tables
10
+ FROM pg_roles r WHERE r.rolname=current_user`
11
+ if (!role || role.rolsuper !== false || role.rolbypassrls !== false || role.unsafe_membership !== false || role.unsafe_tables !== false) throw new ExtensionError('forbidden', 403)
12
+ }
13
+
14
+ export function boundedSqlResult(value) {
15
+ const encoded=canonicalJson(value)
16
+ if(Buffer.byteLength(encoded)>60*1024)throw new ExtensionError('invalid-input')
17
+ return JSON.parse(encoded)
18
+ }
19
+
20
+ export function assertSqlOperationId(value) {
21
+ if(!/^[A-Za-z0-9_-]{16,128}$/.test(text(value)))throw new ExtensionError('invalid-input')
22
+ }
@@ -5,9 +5,10 @@ import { VALID_AUDIENCES as PROJECTION_AUDIENCES } from '../projection/policy.mj
5
5
  import {
6
6
  REPO_ACCESS_SCHEMA,
7
7
  VALID_RELATION_TYPES,
8
- buildKnowledgeGraph,
8
+ buildKnowledgeGraphSteps,
9
9
  createGraphFileCache,
10
10
  parseYamlSubset,
11
+ runGraphSteps,
11
12
  } from './knowledge-graph.mjs'
12
13
 
13
14
  export { createGraphFileCache }
@@ -116,11 +117,12 @@ function externalRepos(project) {
116
117
  .sort((a, b) => String(a.name).localeCompare(String(b.name)))
117
118
  }
118
119
 
119
- function canonicalBuild(project, overrides = {}) {
120
+ function* canonicalBuildSteps(project, overrides = {}) {
120
121
  const input = canonicalInput(project)
121
- const result = buildKnowledgeGraph({ ...input.options, ...overrides })
122
+ const result = yield* buildKnowledgeGraphSteps({ ...input.options, ...overrides })
122
123
  return { input, result }
123
124
  }
125
+ const canonicalBuild = (project, overrides = {}) => runGraphSteps(canonicalBuildSteps(project, overrides))
124
126
 
125
127
  // The supported project-to-options seam: the exact buildKnowledgeGraph options
126
128
  // every graph consumer derives from a loaded project, plus the configured
@@ -144,8 +146,14 @@ const canonicalEdgeId = (edge) => JSON.stringify([edge.source, edge.type, edge.t
144
146
  // bytes did not change; the result is the same as without it. `observedDigest`
145
147
  // (see createGraphFileCache) lets a caller that observes sources by digest
146
148
  // spare the read of a source it knows unchanged.
147
- export function buildCanonicalGraph(project, { isLinkTargetEligible, isAssetEligible, fileCache = null, observedDigest = null } = {}) {
148
- const { input, result } = canonicalBuild(project, {
149
+ // buildCanonicalGraphSteps is the same build as steps (see buildKnowledgeGraphSteps), for a caller that waits between
150
+ // them; buildCanonicalGraph runs them through.
151
+ export function buildCanonicalGraph(project, options = {}) {
152
+ return runGraphSteps(buildCanonicalGraphSteps(project, options))
153
+ }
154
+
155
+ export function* buildCanonicalGraphSteps(project, { isLinkTargetEligible, isAssetEligible, fileCache = null, observedDigest = null } = {}) {
156
+ const { input, result } = yield* canonicalBuildSteps(project, {
149
157
  ...(isLinkTargetEligible ? { isLinkTargetEligible } : {}),
150
158
  ...(isAssetEligible ? { isAssetEligible } : {}),
151
159
  ...(fileCache ? { fileCache } : {}),
@@ -1054,7 +1054,7 @@ export function resolveWorkspaceLinks({ repos = [], isLinkTargetEligible = () =>
1054
1054
 
1055
1055
  for (const repo of enrolled) {
1056
1056
  for (const [rel, node] of repo.nodesByPath) {
1057
- if (!rel.endsWith('.md') || !isLinkTargetEligible(node)) continue
1057
+ if (!isMarkdownPath(rel) || !isLinkTargetEligible(node)) continue
1058
1058
  const { occurrences, utf8Exact } = scanned?.get(node) ?? scanMarkdownSource(fs.readFileSync(path.join(repo.root, rel)))
1059
1059
  const finding = (code, occurrence, detail, extra = {}) =>
1060
1060
  diagnostics.push({
@@ -1387,7 +1387,24 @@ function markdownCensus({ repoName, repoRoot, coverage, file, accessConfig, cach
1387
1387
  return { node: structuredClone(entry.node), scan: structuredClone(entry.scan), reused: entry === cached, read }
1388
1388
  }
1389
1389
 
1390
- export function buildKnowledgeGraph({
1390
+ // Runs the steps of a graph build through, with no wait, and answers what the build answers.
1391
+ export function runGraphSteps(steps) {
1392
+ for (;;) {
1393
+ const step = steps.next()
1394
+ if (step.done) return step.value
1395
+ }
1396
+ }
1397
+
1398
+ // The knowledge graph of a workspace, in one stretch. Every caller but the maintenance engine's tick builds it this way.
1399
+ export function buildKnowledgeGraph(options) {
1400
+ return runGraphSteps(buildKnowledgeGraphSteps(options))
1401
+ }
1402
+
1403
+ // The same build as steps: one after each Markdown source of the census (its read, hash and parse, or its reuse from
1404
+ // the file cache), named 'graph-census'. A caller that gives the event loop a turn between steps (the maintenance
1405
+ // engine) keeps a large census from holding it; walking the directories, resolving links and assembling the graph
1406
+ // are not split. The answer is the one buildKnowledgeGraph gives.
1407
+ export function* buildKnowledgeGraphSteps({
1391
1408
  workspaceRoot,
1392
1409
  repoAccessConfig,
1393
1410
  repoRoots = null,
@@ -1466,6 +1483,7 @@ export function buildKnowledgeGraph({
1466
1483
  scanned.set(node, entry.scan)
1467
1484
  reuse[entry.reused ? 'reused' : 'derived'] += 1
1468
1485
  if (entry.read) reuse.read += 1
1486
+ yield 'graph-census'
1469
1487
  } else {
1470
1488
  node = nodeForFile(repoName, repoRoot, coverage, file, accessConfig)
1471
1489
  }