@mnstry/atelier 0.2.0-alpha.1

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 (260) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +262 -0
  5. package/SECURITY.md +93 -0
  6. package/TRADEMARKS.md +88 -0
  7. package/announcements/2026-08-06-announcements-channel.v1.json +12 -0
  8. package/announcements/keys/mnstry-announcements.public.v1.json +9 -0
  9. package/bin/atelier.mjs +2 -0
  10. package/bin/mnstry-atelier.mjs +3 -0
  11. package/contracts/analysis-adapter.v1.schema.json +248 -0
  12. package/contracts/atelier-action-intent.v1.schema.json +143 -0
  13. package/contracts/atelier-analysis-adapter.v1.schema.json +360 -0
  14. package/contracts/atelier-attestation.v1.schema.json +239 -0
  15. package/contracts/atelier-boundary-policy.v1.schema.json +339 -0
  16. package/contracts/atelier-claim.v1.schema.json +97 -0
  17. package/contracts/atelier-export.v1.schema.json +1015 -0
  18. package/contracts/atelier-extension-pack.v1.schema.json +290 -0
  19. package/contracts/atelier-kit-manifest.v1.schema.json +273 -0
  20. package/contracts/atelier-lock.v1.schema.json +364 -0
  21. package/contracts/atelier-migration.v1.schema.json +162 -0
  22. package/contracts/atelier-project-config.v1.schema.json +352 -0
  23. package/contracts/atelier-readiness-protocol.v1.schema.json +517 -0
  24. package/contracts/atelier-readiness-run.v1.schema.json +229 -0
  25. package/contracts/atelier-readiness.v1.schema.json +457 -0
  26. package/contracts/compat-baseline.json +10 -0
  27. package/contracts/git-promote-event.v1.schema.json +145 -0
  28. package/contracts/knowledge-source-sidecar.v1.schema.json +157 -0
  29. package/contracts/mnstry-atelier-semantic-profile.v1.json +254 -0
  30. package/docs/announcements.md +101 -0
  31. package/docs/atelier-runtime.md +34 -0
  32. package/docs/attestation.md +201 -0
  33. package/docs/continuity.md +55 -0
  34. package/docs/contract-stability.md +111 -0
  35. package/docs/distributions.md +220 -0
  36. package/docs/install.md +140 -0
  37. package/docs/knowledge-graph.md +140 -0
  38. package/docs/ontology.md +206 -0
  39. package/docs/release-engineering.md +151 -0
  40. package/docs/repo-boundary-guard.md +199 -0
  41. package/docs/tenant-readiness.md +64 -0
  42. package/docs/upgrade.md +124 -0
  43. package/fixtures/analysis/invalid/native-graph.v1.json +4 -0
  44. package/fixtures/analysis/invalid/promoted-claim.v1.json +17 -0
  45. package/fixtures/analysis/invalid/runtime-import.v1.json +20 -0
  46. package/fixtures/analysis/valid/output.claims.v1.json +17 -0
  47. package/fixtures/analysis-adapter/invalid/manifest-hidden-provider.v1.json +24 -0
  48. package/fixtures/analysis-adapter/invalid/output-frontmatter-mutation.v1.json +21 -0
  49. package/fixtures/analysis-adapter/invalid/output-native-graph.v1.json +14 -0
  50. package/fixtures/analysis-adapter/manifest.disabled.v1.json +22 -0
  51. package/fixtures/analysis-adapter/manifest.enabled-local.v1.json +23 -0
  52. package/fixtures/analysis-adapter/output.claims.v1.json +21 -0
  53. package/fixtures/atelier-action-intent/invalid/runtime-mutation-authority.v1.json +16 -0
  54. package/fixtures/atelier-action-intent/invalid/unknown-top-level-field.v1.json +17 -0
  55. package/fixtures/atelier-action-intent/invalid/writes-beyond-clipboard.v1.json +16 -0
  56. package/fixtures/atelier-action-intent/valid/copy-agent-prompt.v1.json +16 -0
  57. package/fixtures/atelier-action-intent/valid/copy-repo-path.v1.json +22 -0
  58. package/fixtures/atelier-analysis-adapter/invalid/hidden-model-provider.v1.json +24 -0
  59. package/fixtures/atelier-analysis-adapter/invalid/inert-with-execute-command.v1.json +24 -0
  60. package/fixtures/atelier-analysis-adapter/invalid/proposals-with-runtime-import.v1.json +30 -0
  61. package/fixtures/atelier-analysis-adapter/valid/manifest-inert.v1.json +24 -0
  62. package/fixtures/atelier-analysis-adapter/valid/proposals-claim-only.v1.json +27 -0
  63. package/fixtures/atelier-attestation/invalid/bad-digest.v1.json +23 -0
  64. package/fixtures/atelier-attestation/invalid/conformance-scope.v1.json +23 -0
  65. package/fixtures/atelier-attestation/invalid/missing-signature.v1.json +22 -0
  66. package/fixtures/atelier-attestation/invalid/unknown-root-field.v1.json +24 -0
  67. package/fixtures/atelier-attestation/keys/roundtrip-issuer.public.v1.json +9 -0
  68. package/fixtures/atelier-attestation/valid/rejected-with-reasons.v1.json +49 -0
  69. package/fixtures/atelier-attestation/valid/signed-admitted.v1.json +35 -0
  70. package/fixtures/atelier-attestation/valid/signed-roundtrip.v1.json +29 -0
  71. package/fixtures/atelier-attestation/valid/unsigned-advisory.v1.json +23 -0
  72. package/fixtures/atelier-claim/invalid/unknown-provider.v1.json +15 -0
  73. package/fixtures/atelier-claim/invalid/unknown-top-level-field.v1.json +16 -0
  74. package/fixtures/atelier-claim/invalid/wrong-schema-const.v1.json +15 -0
  75. package/fixtures/atelier-claim/valid/adapter-style-proposed-relation.v1.json +19 -0
  76. package/fixtures/atelier-claim/valid/codex-proposed-relation.v1.json +16 -0
  77. package/fixtures/atelier-claim/valid/manual-proposed-relation.v1.json +16 -0
  78. package/fixtures/atelier-claim/valid/readiness-proposed-relation.v1.json +18 -0
  79. package/fixtures/atelier-export/invalid/dirty-unforced-empty-taint.v1.json +19 -0
  80. package/fixtures/atelier-export/invalid/object-local-audience-visibility.v1.json +11 -0
  81. package/fixtures/atelier-export/invalid/open-object-class-owner.v1.json +23 -0
  82. package/fixtures/atelier-export/invalid/pricing-legacy-amount.v1.json +23 -0
  83. package/fixtures/atelier-export/invalid/public-disguised-internal-source-ref.v1.json +11 -0
  84. package/fixtures/atelier-export/invalid/public-nested-private-source-ref.v1.json +17 -0
  85. package/fixtures/atelier-export/invalid/public-resolved-private-source-ref.v1.json +13 -0
  86. package/fixtures/atelier-export/invalid/public-resolved-sensitive-source-ref.v1.json +13 -0
  87. package/fixtures/atelier-export/invalid/public-source-ref-missing-provenance.v1.json +20 -0
  88. package/fixtures/atelier-export/invalid/public-source-team-kg.json +12 -0
  89. package/fixtures/atelier-export/invalid/source-node-legacy-visibility.v1.json +21 -0
  90. package/fixtures/atelier-export/invalid/source-node-unknown-field.v1.json +18 -0
  91. package/fixtures/atelier-export/invalid/unresolved-public-source-ref.v1.json +11 -0
  92. package/fixtures/atelier-export/sample-studio-offer.v1.json +397 -0
  93. package/fixtures/atelier-extension-pack/invalid/bundled-slug-collision.v1.json +22 -0
  94. package/fixtures/atelier-extension-pack/invalid/escapes-root.v1.json +22 -0
  95. package/fixtures/atelier-extension-pack/invalid/posture-tampered.v1.json +22 -0
  96. package/fixtures/atelier-extension-pack/invalid/protocols/autonomous-authority.v1.json +118 -0
  97. package/fixtures/atelier-extension-pack/invalid/protocols/offer-map.v1.json +109 -0
  98. package/fixtures/atelier-extension-pack/invalid/redefines-runtime-term.v1.json +19 -0
  99. package/fixtures/atelier-extension-pack/invalid/wrong-namespace.v1.json +19 -0
  100. package/fixtures/atelier-extension-pack/valid/protocols/contract-gate.v1.json +109 -0
  101. package/fixtures/atelier-extension-pack/valid/sample-pack.v1.json +48 -0
  102. package/fixtures/atelier-kit-manifest/invalid/runtime-copy-without-canonical-role.v1.json +53 -0
  103. package/fixtures/atelier-kit-manifest/invalid/telemetry-send-path.v1.json +21 -0
  104. package/fixtures/atelier-kit-manifest/valid/mnstry-atelier.valid.v1.json +76 -0
  105. package/fixtures/atelier-lock/invalid/absolute-contract-path.v1.json +40 -0
  106. package/fixtures/atelier-lock/invalid/runtime-mutation-enabled.v1.json +41 -0
  107. package/fixtures/atelier-lock/invalid/unknown-top-level-field.v1.json +42 -0
  108. package/fixtures/atelier-lock/valid/extension-pack.v1.json +55 -0
  109. package/fixtures/atelier-lock/valid/minimal.v1.json +44 -0
  110. package/fixtures/atelier-migration/invalid/live-apply-mode.v1.json +21 -0
  111. package/fixtures/atelier-migration/invalid/missing-review-evidence.v1.json +20 -0
  112. package/fixtures/atelier-migration/invalid/runtime-mutation-enabled.v1.json +21 -0
  113. package/fixtures/atelier-migration/valid/minimal.v1.json +32 -0
  114. package/fixtures/atelier-project-config/invalid/absolute-repo-path.v1.json +11 -0
  115. package/fixtures/atelier-project-config/invalid/ext-not-object.v1.json +12 -0
  116. package/fixtures/atelier-project-config/invalid/external-kind-read-boundary.v1.json +12 -0
  117. package/fixtures/atelier-project-config/invalid/missing-repos-and-alignment.v1.json +7 -0
  118. package/fixtures/atelier-project-config/invalid/unknown-top-level-field.v1.json +14 -0
  119. package/fixtures/atelier-project-config/valid/alignment-scaffold.v1.json +15 -0
  120. package/fixtures/atelier-project-config/valid/ext-extension-packs.v1.json +36 -0
  121. package/fixtures/atelier-project-config/valid/repo-workspace.v1.json +23 -0
  122. package/fixtures/atelier-project-config/validator-only/ext-member-not-object.v1.json +14 -0
  123. package/fixtures/atelier-readiness/invalid/dry-run-only-false.v1.json +87 -0
  124. package/fixtures/atelier-readiness/invalid/support-bundle-send-path.v1.json +87 -0
  125. package/fixtures/atelier-readiness/invalid/unknown-top-level-field.v1.json +88 -0
  126. package/fixtures/atelier-readiness/valid/ready-workspace.v1.json +84 -0
  127. package/fixtures/boundary-policy/invalid/content-rule-exception-blanket-path.v1.json +67 -0
  128. package/fixtures/boundary-policy/invalid/invalid-actor-private-repo-mapping.v1.json +20 -0
  129. package/fixtures/boundary-policy/invalid/legacy-warning-missing-warning.v1.json +20 -0
  130. package/fixtures/boundary-policy/invalid/missing-repo-coverage.v1.json +19 -0
  131. package/fixtures/boundary-policy/invalid/unknown-key.v1.json +12 -0
  132. package/fixtures/boundary-policy/valid/content-rule-exception.v1.json +67 -0
  133. package/fixtures/boundary-policy/valid/legacy-warning.v1.json +33 -0
  134. package/fixtures/boundary-policy/valid/strict-private-shared.v1.json +40 -0
  135. package/fixtures/git-promote/invalid/provision-named-revocable.v1.json +29 -0
  136. package/fixtures/git-promote/private-to-team.v1.json +29 -0
  137. package/fixtures/knowledge-source-sidecar/invalid/legacy-visibility-field.v1.json +17 -0
  138. package/fixtures/knowledge-source-sidecar/invalid/missing-audience.v1.json +15 -0
  139. package/fixtures/knowledge-source-sidecar/invalid/unknown-relation-kind.v1.json +18 -0
  140. package/fixtures/knowledge-source-sidecar/valid/html-source.v1.json +18 -0
  141. package/fixtures/knowledge-source-sidecar/valid/pdf-evidence.v1.json +18 -0
  142. package/fixtures/projects/neutral-project/atelier.project.json +15 -0
  143. package/fixtures/projects/neutral-project/ops/config/repo-access.v1.json +12 -0
  144. package/fixtures/projects/neutral-project/project-knowledge.graph.json +69 -0
  145. package/fixtures/projects/sample-workspace/atelier.project.json +23 -0
  146. package/fixtures/projects/sample-workspace/boundary-policy.v1.json +59 -0
  147. package/fixtures/projects/sample-workspace/content/README.md +16 -0
  148. package/fixtures/projects/sample-workspace/content/source.html +5 -0
  149. package/fixtures/projects/sample-workspace/content/source.html.kg.json +15 -0
  150. package/fixtures/projects/sample-workspace/repo-access.v1.json +9 -0
  151. package/fixtures/projects/source-formats-workspace/atelier.project.json +23 -0
  152. package/fixtures/projects/source-formats-workspace/content/README.md +17 -0
  153. package/fixtures/projects/source-formats-workspace/content/data.json +7 -0
  154. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +15 -0
  155. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +15 -0
  156. package/fixtures/projects/source-formats-workspace/content/metrics.csv +3 -0
  157. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +15 -0
  158. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml +4 -0
  159. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +15 -0
  160. package/fixtures/projects/source-formats-workspace/repo-access.v1.json +9 -0
  161. package/fixtures/readiness-protocols/extension-pack/invalid/protocol-missing-path.v1.json +20 -0
  162. package/fixtures/readiness-protocols/extension-pack/valid/with-protocol.v1.json +24 -0
  163. package/fixtures/readiness-protocols/protocol/invalid/missing-check-target.v1.json +103 -0
  164. package/fixtures/readiness-protocols/protocol/invalid/runtime-mutation-enabled.v1.json +107 -0
  165. package/fixtures/readiness-protocols/protocol/valid/minimal.v1.json +100 -0
  166. package/fixtures/readiness-protocols/run/invalid/missing-evidence.v1.json +37 -0
  167. package/fixtures/readiness-protocols/run/invalid/pass-with-blocker.v1.json +22 -0
  168. package/fixtures/readiness-protocols/run/invalid/runtime-mutation-enabled.v1.json +22 -0
  169. package/fixtures/readiness-protocols/run/valid/pass.v1.json +44 -0
  170. package/fixtures/support/invalid/leaky-support.v1.json +13 -0
  171. package/fixtures/support/valid/minimal-safe.v1.json +20 -0
  172. package/package.json +98 -0
  173. package/skills/claude/atelier-open/SKILL.md +68 -0
  174. package/skills/claude/mnstry-readiness/SKILL.md +41 -0
  175. package/skills/codex/atelier-open/SKILL.md +68 -0
  176. package/skills/codex/mnstry-readiness/SKILL.md +41 -0
  177. package/src/analysis/adapter.mjs +20 -0
  178. package/src/analysis/analysis-claim-contract.mjs +335 -0
  179. package/src/attestation/jcs.mjs +64 -0
  180. package/src/attestation/sign.mjs +305 -0
  181. package/src/boundary/content-rules.mjs +262 -0
  182. package/src/boundary/policy.mjs +806 -0
  183. package/src/check-atelier-export-contract.mjs +116 -0
  184. package/src/cli/run.mjs +283 -0
  185. package/src/collaboration/proposals.mjs +262 -0
  186. package/src/commands/analysis.mjs +8 -0
  187. package/src/commands/announcements.mjs +308 -0
  188. package/src/commands/attestation.mjs +198 -0
  189. package/src/commands/boundary.mjs +25 -0
  190. package/src/commands/config.mjs +4 -0
  191. package/src/commands/context.mjs +3 -0
  192. package/src/commands/distribution.mjs +224 -0
  193. package/src/commands/egress.mjs +11 -0
  194. package/src/commands/extension-pack.mjs +209 -0
  195. package/src/commands/feedback.mjs +3 -0
  196. package/src/commands/graph.mjs +3 -0
  197. package/src/commands/init.mjs +130 -0
  198. package/src/commands/lock.mjs +3 -0
  199. package/src/commands/project.mjs +3 -0
  200. package/src/commands/promote.mjs +4 -0
  201. package/src/commands/readiness.mjs +3 -0
  202. package/src/commands/server.mjs +6 -0
  203. package/src/commands/setup.mjs +250 -0
  204. package/src/commands/support.mjs +3 -0
  205. package/src/commands/upgrade.mjs +3 -0
  206. package/src/contracts/corpus.mjs +171 -0
  207. package/src/egress/check.mjs +45 -0
  208. package/src/egress/forbidden-egress.mjs +323 -0
  209. package/src/export/atelier-export-contract.mjs +722 -0
  210. package/src/extension-packs/loader.mjs +491 -0
  211. package/src/graph/graph.mjs +370 -0
  212. package/src/graph/knowledge-graph.mjs +887 -0
  213. package/src/harness/context-client.mjs +144 -0
  214. package/src/harness/context.mjs +27 -0
  215. package/src/index.mjs +88 -0
  216. package/src/project/config.mjs +557 -0
  217. package/src/project/file-class.mjs +113 -0
  218. package/src/project/git-ignore.mjs +31 -0
  219. package/src/project/path-match.mjs +30 -0
  220. package/src/project/repo-identity.mjs +176 -0
  221. package/src/projection/alignment-projection.mjs +169 -0
  222. package/src/projection/policy.mjs +90 -0
  223. package/src/projection/project.mjs +152 -0
  224. package/src/readiness/readiness.mjs +312 -0
  225. package/src/readiness-protocols/bundled-pack.mjs +766 -0
  226. package/src/readiness-protocols/runtime.mjs +419 -0
  227. package/src/server/local-sidecar.mjs +640 -0
  228. package/src/server/security.mjs +229 -0
  229. package/src/server/server.mjs +42 -0
  230. package/src/support/feedback-report.mjs +414 -0
  231. package/src/support/support-bundle.mjs +226 -0
  232. package/src/ui/html-primitives.mjs +104 -0
  233. package/src/upgrade/upgrade.mjs +701 -0
  234. package/src/validate-atelier-export-dry-run.mjs +75 -0
  235. package/templates/atelier.project.example.json +23 -0
  236. package/templates/distribution-workspace/.claude/launch.json +12 -0
  237. package/templates/distribution-workspace/README.md +86 -0
  238. package/templates/distribution-workspace/atelier.project.json +36 -0
  239. package/templates/distribution-workspace/boundary-policy.v1.json +61 -0
  240. package/templates/distribution-workspace/gitignore +29 -0
  241. package/templates/distribution-workspace/governance/repo-boundary-ledger.md +5 -0
  242. package/templates/distribution-workspace/project/README.md +19 -0
  243. package/templates/distribution-workspace/repo-access.v1.json +9 -0
  244. package/templates/launch.json +12 -0
  245. package/templates/private-domain-workspace/.claude/launch.json +12 -0
  246. package/templates/private-domain-workspace/README.md +50 -0
  247. package/templates/private-domain-workspace/atelier.project.json +28 -0
  248. package/templates/private-domain-workspace/boundary-policy.v1.json +54 -0
  249. package/templates/private-domain-workspace/domain/README.md +19 -0
  250. package/templates/private-domain-workspace/gitignore +29 -0
  251. package/templates/private-domain-workspace/governance/repo-boundary-ledger.md +4 -0
  252. package/templates/private-domain-workspace/repo-access.v1.json +9 -0
  253. package/templates/shared-project-workspace/.claude/launch.json +12 -0
  254. package/templates/shared-project-workspace/README.md +51 -0
  255. package/templates/shared-project-workspace/atelier.project.json +28 -0
  256. package/templates/shared-project-workspace/boundary-policy.v1.json +61 -0
  257. package/templates/shared-project-workspace/gitignore +29 -0
  258. package/templates/shared-project-workspace/governance/repo-boundary-ledger.md +5 -0
  259. package/templates/shared-project-workspace/project/README.md +19 -0
  260. package/templates/shared-project-workspace/repo-access.v1.json +9 -0
@@ -0,0 +1,640 @@
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from 'node:crypto'
4
+ import fs from 'node:fs'
5
+ import http from 'node:http'
6
+ import path from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import {
9
+ expectedOriginForRequest,
10
+ htmlDocumentHeaders,
11
+ requestHeader,
12
+ resolveWorkspacePath,
13
+ staticHeaders,
14
+ trustedFetchSite,
15
+ trustedHost,
16
+ trustedMutationRequest,
17
+ trustedReadRequest,
18
+ } from './security.mjs'
19
+ import { createProposalStore } from '../collaboration/proposals.mjs'
20
+ import {
21
+ renderProposalDetailPageHtml,
22
+ renderProposalListPageHtml,
23
+ } from '../ui/html-primitives.mjs'
24
+
25
+ export const ATELIER_CONTEXT_SCHEMA = 'atelier-context@v1'
26
+ export const ATELIER_PRESENCE_SCHEMA = 'atelier-presence@v1'
27
+ export const ATELIER_RESOLVE_SCHEMA = 'atelier-resolve@v1'
28
+ export const ATELIER_DOCTOR_SCHEMA = 'atelier-doctor@v1'
29
+ export const ATELIER_CAPABILITIES_SCHEMA = 'atelier-capabilities@v1'
30
+ export const ATELIER_SESSION_AUTH_SCHEMA = 'atelier-session-auth@v1'
31
+
32
+ function workspaceIdForRoot(workspaceRoot) {
33
+ return crypto.createHash('sha256').update(fs.realpathSync(workspaceRoot)).digest('hex').slice(0, 16)
34
+ }
35
+
36
+ function cleanIdentity(value, max = 160) {
37
+ return String(value || '').trim().slice(0, max)
38
+ }
39
+
40
+ function nowMs() {
41
+ return Date.now()
42
+ }
43
+
44
+ function readJsonFile(file, fallback) {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
47
+ } catch {
48
+ return fallback
49
+ }
50
+ }
51
+
52
+ function secureWriteJson(file, payload, mode = 0o600) {
53
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
54
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
55
+ fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode })
56
+ fs.renameSync(tmp, file)
57
+ try {
58
+ fs.chmodSync(file, mode)
59
+ } catch {
60
+ // Best effort on filesystems that do not support chmod.
61
+ }
62
+ }
63
+
64
+ function readOrCreateNonce(noncePath) {
65
+ const existing = fs.existsSync(noncePath) ? fs.readFileSync(noncePath, 'utf8').trim() : ''
66
+ if (/^[a-f0-9]{64}$/.test(existing)) return existing
67
+ const nonce = crypto.randomBytes(32).toString('hex')
68
+ fs.writeFileSync(noncePath, `${nonce}\n`, { mode: 0o600 })
69
+ return nonce
70
+ }
71
+
72
+ function json(res, code, obj) {
73
+ const body = Buffer.from(`${JSON.stringify(obj, null, 2)}\n`)
74
+ res.writeHead(code, {
75
+ 'Content-Type': 'application/json; charset=utf-8',
76
+ 'Content-Length': String(body.length),
77
+ 'Cache-Control': 'no-store',
78
+ 'X-Content-Type-Options': 'nosniff',
79
+ })
80
+ res.end(body)
81
+ }
82
+
83
+ function text(res, code, body, headers = {}) {
84
+ const buffer = Buffer.from(body)
85
+ res.writeHead(code, {
86
+ 'Content-Type': 'text/plain; charset=utf-8',
87
+ 'Content-Length': String(buffer.length),
88
+ 'Cache-Control': 'no-store',
89
+ ...headers,
90
+ })
91
+ res.end(buffer)
92
+ }
93
+
94
+ function readBody(req, maxBytes = 1024 * 1024) {
95
+ return new Promise((resolve, reject) => {
96
+ const chunks = []
97
+ let total = 0
98
+ req.on('data', (chunk) => {
99
+ total += chunk.length
100
+ if (total > maxBytes) {
101
+ reject(new Error('request body too large'))
102
+ req.destroy()
103
+ return
104
+ }
105
+ chunks.push(chunk)
106
+ })
107
+ req.on('end', () => {
108
+ const textBody = Buffer.concat(chunks).toString('utf8')
109
+ if (!textBody) {
110
+ resolve({})
111
+ return
112
+ }
113
+ try {
114
+ resolve(JSON.parse(textBody))
115
+ } catch {
116
+ reject(new Error('request body must be JSON'))
117
+ }
118
+ })
119
+ req.on('error', reject)
120
+ })
121
+ }
122
+
123
+ function requestNonce(req, body = {}) {
124
+ return requestHeader(req.headers, 'x-atelier-nonce') || body.mutationNonce || body.nonce || ''
125
+ }
126
+
127
+ function nonceMatches(value, mutationNonce) {
128
+ return typeof value === 'string' && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(mutationNonce))
129
+ }
130
+
131
+ function requireMutationAuth(req, body, mutationNonce) {
132
+ if (!trustedMutationRequest(req.headers)) {
133
+ return { ok: false, status: 403, error: 'cross-origin mutation refused' }
134
+ }
135
+ const nonce = requestNonce(req, body)
136
+ if (!/^[a-f0-9]{64}$/.test(nonce) || !nonceMatches(nonce, mutationNonce)) {
137
+ return { ok: false, status: 403, error: 'mutation nonce required' }
138
+ }
139
+ return { ok: true }
140
+ }
141
+
142
+ function defaultPresence() {
143
+ return {
144
+ schema: ATELIER_PRESENCE_SCHEMA,
145
+ entries: [],
146
+ }
147
+ }
148
+
149
+ function presenceKey(entry) {
150
+ return [entry.sessionId, entry.viewId, entry.path].filter(Boolean).join('\0')
151
+ }
152
+
153
+ function rankedEntries(entries, ttlMs, now = nowMs()) {
154
+ return entries
155
+ .map((entry) => ({
156
+ ...entry,
157
+ stale: Number(entry.seenAtMs || 0) + ttlMs < now,
158
+ }))
159
+ .sort((left, right) => Number(right.seenAtMs || 0) - Number(left.seenAtMs || 0))
160
+ }
161
+
162
+ function capabilityContract() {
163
+ return {
164
+ ok: true,
165
+ schema: ATELIER_CAPABILITIES_SCHEMA,
166
+ read: [
167
+ { id: 'context.current', endpoint: '/api/current' },
168
+ { id: 'context.resolve', endpoint: '/api/resolve' },
169
+ { id: 'doctor.status', endpoint: '/api/doctor' },
170
+ { id: 'capabilities.contract', endpoint: '/api/capabilities' },
171
+ ],
172
+ mutate: [
173
+ { id: 'presence.seen', endpoint: '/api/seen', copyOnly: true },
174
+ { id: 'proposal.create', endpoint: '/api/proposals', copyOnly: true },
175
+ { id: 'proposal.review', endpoint: '/api/proposals/:id/review', copyOnly: true },
176
+ ],
177
+ directWrite: false,
178
+ applyEndpoint: null,
179
+ summary: {
180
+ read: true,
181
+ mutate: false,
182
+ directWrite: false,
183
+ },
184
+ grantEndpoint: '/api/session-auth',
185
+ proposalEndpoint: '/api/proposals',
186
+ }
187
+ }
188
+
189
+ function doctorEnvelope() {
190
+ return {
191
+ ok: true,
192
+ schema: ATELIER_DOCTOR_SCHEMA,
193
+ status: 'ok',
194
+ checkedBeforeRepair: true,
195
+ errors: [],
196
+ warnings: [],
197
+ }
198
+ }
199
+
200
+ export function createAtelierSidecarServer({
201
+ workspaceRoot = process.cwd(),
202
+ stateDir = workspaceRoot,
203
+ port = 0,
204
+ presenceTtlMs = Number(process.env.ATELIER_PRESENCE_TTL_MS || 5 * 60 * 1000),
205
+ } = {}) {
206
+ const root = path.resolve(workspaceRoot)
207
+ const rootReal = fs.realpathSync(root)
208
+ const workspaceId = workspaceIdForRoot(root)
209
+ const noncePath = path.join(stateDir, '.atelier-nonce')
210
+ const presencePath = path.join(stateDir, '.atelier-presence.json')
211
+ const mutationNonce = readOrCreateNonce(noncePath)
212
+ const proposals = createProposalStore({
213
+ workspaceRoot: root,
214
+ proposalsDir: path.join(stateDir, '.atelier-proposals'),
215
+ workspaceId,
216
+ })
217
+
218
+ function readPresence() {
219
+ const presence = readJsonFile(presencePath, defaultPresence())
220
+ return {
221
+ ...defaultPresence(),
222
+ ...presence,
223
+ entries: Array.isArray(presence?.entries) ? presence.entries : [],
224
+ }
225
+ }
226
+
227
+ function writePresence(presence) {
228
+ secureWriteJson(presencePath, presence)
229
+ }
230
+
231
+ function recordView(rel, hints = {}) {
232
+ const resolved = resolveWorkspacePath({
233
+ workspaceRoot: root,
234
+ workspaceRootReal: rootReal,
235
+ rel,
236
+ requireHtml: true,
237
+ })
238
+ if (!resolved.ok) return { ok: false, error: 'unknown workspace html file' }
239
+ const entry = {
240
+ sessionId: cleanIdentity(hints.sessionId, 120),
241
+ viewId: cleanIdentity(hints.viewId, 120),
242
+ path: resolved.path,
243
+ seenAt: new Date().toISOString(),
244
+ seenAtMs: nowMs(),
245
+ workspaceId,
246
+ }
247
+ const presence = readPresence()
248
+ const byKey = new Map(presence.entries.map((item) => [presenceKey(item), item]))
249
+ byKey.set(presenceKey(entry), entry)
250
+ presence.entries = rankedEntries([...byKey.values()], presenceTtlMs).slice(0, 100)
251
+ writePresence(presence)
252
+ return { ok: true, entry }
253
+ }
254
+
255
+ function resolveCurrentContext(hints = {}) {
256
+ const entries = rankedEntries(readPresence().entries, presenceTtlMs)
257
+ const sessionId = cleanIdentity(hints.sessionId, 120)
258
+ if (sessionId) {
259
+ const candidates = entries.filter((entry) => entry.sessionId === sessionId)
260
+ const fresh = candidates.find((entry) => !entry.stale)
261
+ if (fresh) {
262
+ return {
263
+ ok: true,
264
+ schema: ATELIER_CONTEXT_SCHEMA,
265
+ confidence: 'bound',
266
+ current: { path: fresh.path },
267
+ session: { sessionId: fresh.sessionId, viewId: fresh.viewId },
268
+ candidates,
269
+ }
270
+ }
271
+ if (candidates[0]) {
272
+ return {
273
+ ok: true,
274
+ schema: ATELIER_CONTEXT_SCHEMA,
275
+ confidence: 'fallback',
276
+ stale: true,
277
+ current: { path: candidates[0].path },
278
+ session: { sessionId: candidates[0].sessionId, viewId: candidates[0].viewId },
279
+ candidates,
280
+ }
281
+ }
282
+ }
283
+ return {
284
+ ok: true,
285
+ schema: ATELIER_CONTEXT_SCHEMA,
286
+ confidence: entries.length === 1 && !entries[0].stale ? 'single-candidate' : 'ambiguous',
287
+ current: entries[0] ? { path: entries[0].path } : null,
288
+ fallback: entries[0] ? { path: entries[0].path } : null,
289
+ candidates: entries,
290
+ }
291
+ }
292
+
293
+ function contextEnvelope(req, url) {
294
+ const sessionId = cleanIdentity(url.searchParams.get('sessionId'), 120)
295
+ const viewId = cleanIdentity(url.searchParams.get('viewId'), 120)
296
+ const rel = cleanIdentity(url.searchParams.get('path') || 'index.html', 500)
297
+ const expectedWorkspaceId = cleanIdentity(url.searchParams.get('expectedWorkspaceId'), 120)
298
+ const resolved = resolveWorkspacePath({
299
+ workspaceRoot: root,
300
+ workspaceRootReal: rootReal,
301
+ rel,
302
+ requireHtml: true,
303
+ })
304
+ const current = resolveCurrentContext({ sessionId })
305
+ const workspaceVerified = Boolean(expectedWorkspaceId && expectedWorkspaceId === workspaceId)
306
+ const caps = capabilityContract()
307
+ caps.summary = {
308
+ ...caps.summary,
309
+ mutate: workspaceVerified && current.confidence === 'bound',
310
+ }
311
+ return {
312
+ ok: true,
313
+ schema: ATELIER_CONTEXT_SCHEMA,
314
+ workspaceId,
315
+ origin: {
316
+ host: requestHeader(req.headers, 'host'),
317
+ trustedHost: trustedHost(req.headers),
318
+ },
319
+ sessionId,
320
+ viewId,
321
+ view: { path: rel },
322
+ presence: {
323
+ authoritative: workspaceVerified && current.confidence === 'bound',
324
+ sessionBound: current.confidence === 'bound',
325
+ workspaceVerified,
326
+ workspaceMismatch: Boolean(expectedWorkspaceId && expectedWorkspaceId !== workspaceId),
327
+ expectedWorkspaceId: expectedWorkspaceId || null,
328
+ candidates: current.candidates || [],
329
+ },
330
+ current: current.current,
331
+ resolve: resolved.ok ? {
332
+ ok: true,
333
+ schema: ATELIER_RESOLVE_SCHEMA,
334
+ path: resolved.path,
335
+ workspaceContained: true,
336
+ } : {
337
+ ok: false,
338
+ schema: ATELIER_RESOLVE_SCHEMA,
339
+ path: rel,
340
+ workspaceContained: false,
341
+ error: resolved.error,
342
+ },
343
+ doctor: doctorEnvelope(),
344
+ capabilities: caps,
345
+ }
346
+ }
347
+
348
+ async function handlePost(req, res, url) {
349
+ let body
350
+ try {
351
+ body = await readBody(req)
352
+ } catch (error) {
353
+ json(res, 400, { ok: false, error: error.message })
354
+ return
355
+ }
356
+ const auth = requireMutationAuth(req, body, mutationNonce)
357
+ if (!auth.ok) {
358
+ json(res, auth.status, { ok: false, error: auth.error })
359
+ return
360
+ }
361
+
362
+ if (url.pathname === '/api/seen') {
363
+ const result = recordView(body.rel || body.path, body)
364
+ json(res, 200, result.ok ? { ok: true, entry: result.entry } : result)
365
+ return
366
+ }
367
+
368
+ if (url.pathname === '/api/proposals') {
369
+ const resolved = resolveWorkspacePath({
370
+ workspaceRoot: root,
371
+ workspaceRootReal: rootReal,
372
+ rel: body.path || body.rel,
373
+ requireHtml: true,
374
+ })
375
+ if (!resolved.ok) {
376
+ json(res, 200, { ok: false, error: 'unknown workspace html file' })
377
+ return
378
+ }
379
+ let result
380
+ try {
381
+ result = proposals.createProposal({ ...body, path: resolved.path })
382
+ } catch (error) {
383
+ json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
384
+ return
385
+ }
386
+ if (!result.ok) {
387
+ json(res, result.status, { ok: false, error: result.error })
388
+ return
389
+ }
390
+ json(res, 200, {
391
+ ok: true,
392
+ schema: result.record.schema,
393
+ workspaceId,
394
+ proposal: result.record.proposal,
395
+ })
396
+ return
397
+ }
398
+
399
+ const reviewMatch = url.pathname.match(/^\/api\/proposals\/([^/]+)\/review$/)
400
+ if (reviewMatch) {
401
+ const id = reviewMatch[1]
402
+ let result
403
+ try {
404
+ result = proposals.reviewProposal(id, body)
405
+ } catch (error) {
406
+ json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
407
+ return
408
+ }
409
+ if (!result.ok) {
410
+ json(res, result.status, { ok: false, error: result.error })
411
+ return
412
+ }
413
+ json(res, 200, {
414
+ ok: true,
415
+ schema: result.record.schema,
416
+ proposal: result.record.proposal,
417
+ diff: result.record.diff,
418
+ copyable: result.record.copyable,
419
+ })
420
+ return
421
+ }
422
+
423
+ json(res, 404, { ok: false, error: 'unknown action' })
424
+ }
425
+
426
+ function serveStatic(req, res, url) {
427
+ if (!trustedReadRequest(req.headers)) {
428
+ text(res, 403, 'forbidden\n')
429
+ return
430
+ }
431
+
432
+ const rel = url.pathname === '/' ? 'index.html' : url.pathname.slice(1)
433
+ const resolved = resolveWorkspacePath({
434
+ workspaceRoot: root,
435
+ workspaceRootReal: rootReal,
436
+ rel,
437
+ requireFile: true,
438
+ })
439
+ if (!resolved.ok) {
440
+ text(res, resolved.status || 403, `${resolved.error || 'forbidden'}\n`)
441
+ return
442
+ }
443
+
444
+ const sessionId = url.searchParams.get('sessionId')
445
+ const viewId = url.searchParams.get('viewId')
446
+ if (sessionId && trustedFetchSite(req.headers)) {
447
+ recordView(resolved.path, { sessionId, viewId })
448
+ }
449
+
450
+ const headers = path.extname(resolved.real).toLowerCase() === '.html'
451
+ ? htmlDocumentHeaders(resolved.stat.size)
452
+ : staticHeaders(resolved.real, resolved.stat)
453
+ res.writeHead(200, headers)
454
+ fs.createReadStream(resolved.real).pipe(res)
455
+ }
456
+
457
+ async function handleRequest(req, res) {
458
+ const expectedOrigin = expectedOriginForRequest(req.headers, port)
459
+ const url = new URL(req.url || '/', expectedOrigin || 'http://127.0.0.1')
460
+
461
+ if (!trustedHost(req.headers)) {
462
+ json(res, 403, { ok: false, error: 'non-loopback Host refused' })
463
+ return
464
+ }
465
+
466
+ if (req.method === 'POST') {
467
+ await handlePost(req, res, url)
468
+ return
469
+ }
470
+
471
+ if (url.pathname === '/api/health') {
472
+ json(res, 200, { ok: true, workspaceId })
473
+ return
474
+ }
475
+ if (url.pathname === '/api/session-auth') {
476
+ if (!trustedMutationRequest(req.headers)) {
477
+ json(res, 403, { ok: false, error: 'cross-origin session auth refused' })
478
+ return
479
+ }
480
+ const rel = url.searchParams.get('path') || 'index.html'
481
+ const resolved = resolveWorkspacePath({
482
+ workspaceRoot: root,
483
+ workspaceRootReal: rootReal,
484
+ rel,
485
+ requireHtml: true,
486
+ })
487
+ if (!resolved.ok) {
488
+ json(res, 404, { ok: false, error: 'unknown workspace html file' })
489
+ return
490
+ }
491
+ recordView(resolved.path, {
492
+ sessionId: url.searchParams.get('sessionId'),
493
+ viewId: url.searchParams.get('viewId'),
494
+ })
495
+ json(res, 200, {
496
+ ok: true,
497
+ schema: ATELIER_SESSION_AUTH_SCHEMA,
498
+ workspaceId,
499
+ mutationNonce,
500
+ })
501
+ return
502
+ }
503
+ if (url.pathname === '/api/current') {
504
+ json(res, 200, resolveCurrentContext({ sessionId: url.searchParams.get('sessionId') }))
505
+ return
506
+ }
507
+ if (url.pathname === '/api/context') {
508
+ json(res, 200, contextEnvelope(req, url))
509
+ return
510
+ }
511
+ if (url.pathname === '/api/resolve') {
512
+ const resolved = resolveWorkspacePath({
513
+ workspaceRoot: root,
514
+ workspaceRootReal: rootReal,
515
+ rel: url.searchParams.get('path') || 'index.html',
516
+ requireHtml: true,
517
+ })
518
+ json(res, resolved.ok ? 200 : (resolved.status || 404), {
519
+ ok: resolved.ok,
520
+ schema: ATELIER_RESOLVE_SCHEMA,
521
+ path: resolved.path || url.searchParams.get('path') || 'index.html',
522
+ workspaceContained: Boolean(resolved.ok),
523
+ error: resolved.ok ? undefined : resolved.error,
524
+ })
525
+ return
526
+ }
527
+ if (url.pathname === '/api/doctor') {
528
+ json(res, 200, doctorEnvelope())
529
+ return
530
+ }
531
+ if (url.pathname === '/api/capabilities') {
532
+ json(res, 200, capabilityContract())
533
+ return
534
+ }
535
+ if (url.pathname === '/api/proposals') {
536
+ json(res, 200, {
537
+ ok: true,
538
+ schema: 'atelier-proposals@v1',
539
+ workspaceId,
540
+ proposals: proposals.listProposals(),
541
+ })
542
+ return
543
+ }
544
+ const proposalReadMatch = url.pathname.match(/^\/api\/proposals\/([^/]+)$/)
545
+ if (proposalReadMatch) {
546
+ const record = proposals.readProposal(proposalReadMatch[1])
547
+ json(res, record ? 200 : 404, record ? {
548
+ ok: true,
549
+ schema: record.schema,
550
+ workspaceId,
551
+ proposal: record.proposal,
552
+ diff: record.diff,
553
+ copyable: record.copyable,
554
+ } : { ok: false, error: 'proposal not found' })
555
+ return
556
+ }
557
+ if (url.pathname === '/proposals') {
558
+ const body = renderProposalListPageHtml(proposals.listProposals())
559
+ res.writeHead(200, htmlDocumentHeaders(Buffer.byteLength(body)))
560
+ res.end(body)
561
+ return
562
+ }
563
+ const proposalPageMatch = url.pathname.match(/^\/proposals\/([^/]+)$/)
564
+ if (proposalPageMatch) {
565
+ const body = renderProposalDetailPageHtml(proposals.readProposal(proposalPageMatch[1]))
566
+ res.writeHead(200, htmlDocumentHeaders(Buffer.byteLength(body)))
567
+ res.end(body)
568
+ return
569
+ }
570
+
571
+ if (url.pathname.startsWith('/api/')) {
572
+ json(res, 404, { ok: false, error: 'unknown action' })
573
+ return
574
+ }
575
+ serveStatic(req, res, url)
576
+ }
577
+
578
+ // A request must never be able to end the process. Any unhandled throw in a
579
+ // route becomes a 500 for that request; the sidecar keeps serving. Without
580
+ // this, a single malformed path terminated the whole review surface.
581
+ const server = http.createServer((req, res) => {
582
+ handleRequest(req, res).catch((error) => {
583
+ console.error(`[atelier] request failed: ${req.method} ${req.url}`, error)
584
+ if (res.headersSent) {
585
+ res.destroy()
586
+ return
587
+ }
588
+ json(res, 500, { ok: false, error: 'internal error' })
589
+ })
590
+ })
591
+
592
+ return {
593
+ server,
594
+ workspaceRoot: root,
595
+ workspaceRootReal: rootReal,
596
+ workspaceId,
597
+ mutationNonce,
598
+ proposals,
599
+ listen(listenPort = port, host = '127.0.0.1') {
600
+ // A busy port is an ordinary condition, not a crash: surface it as a
601
+ // rejection the CLI can print, instead of an unhandled 'error' event.
602
+ return new Promise((resolve, reject) => {
603
+ const onError = (error) => {
604
+ server.removeListener('listening', onListening)
605
+ reject(error.code === 'EADDRINUSE'
606
+ ? new Error(`port ${listenPort} is already in use — pass --port=<free port> to choose another`)
607
+ : error)
608
+ }
609
+ const onListening = () => {
610
+ server.removeListener('error', onError)
611
+ resolve(server.address())
612
+ }
613
+ server.once('error', onError)
614
+ server.listen(listenPort, host, onListening)
615
+ })
616
+ },
617
+ close() {
618
+ return new Promise((resolve, reject) => {
619
+ server.close((error) => {
620
+ if (error) reject(error)
621
+ else resolve()
622
+ })
623
+ })
624
+ },
625
+ }
626
+ }
627
+
628
+ function main() {
629
+ const port = Number(process.argv[2] || process.env.PORT || 8137)
630
+ const workspaceRoot = process.argv[3] ? path.resolve(process.argv[3]) : process.cwd()
631
+ const sidecar = createAtelierSidecarServer({ workspaceRoot, port })
632
+ sidecar.listen(port).then((address) => {
633
+ console.log(`[atelier-server] http://127.0.0.1:${address.port}`)
634
+ })
635
+ }
636
+
637
+ const currentFile = fileURLToPath(import.meta.url)
638
+ if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) {
639
+ main()
640
+ }