@mnstry/atelier 0.2.0-alpha.4 → 0.2.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/README.md +38 -12
- package/contracts/public-api-baseline.json +57 -0
- package/docs/assurance-controls.md +39 -0
- package/docs/atelier-runtime.md +15 -0
- package/docs/blocks/claims.md +15 -9
- package/docs/design.md +12 -6
- package/docs/install.md +26 -4
- package/docs/knowledge-graph.md +8 -4
- package/docs/local-services.md +101 -0
- package/docs/release-engineering.md +75 -10
- package/docs/repo-boundary-guard.md +12 -2
- package/docs/upgrade.md +25 -2
- package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
- package/package.json +12 -5
- package/skills/claude/atelier-local-service/SKILL.md +47 -0
- package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
- package/skills/codex/atelier-local-service/SKILL.md +47 -0
- package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
- package/src/boundary/content-rules.mjs +278 -20
- package/src/boundary/policy.mjs +150 -60
- package/src/cli/execute-command.mjs +36 -0
- package/src/cli/run.mjs +17 -7
- package/src/collaboration/event-ledger.mjs +365 -0
- package/src/collaboration/index.mjs +17 -0
- package/src/collaboration/proposals.mjs +265 -65
- package/src/commands/attestation.mjs +20 -6
- package/src/commands/disclosure.mjs +133 -0
- package/src/commands/distribution.mjs +2 -1
- package/src/commands/extension-pack.mjs +2 -1
- package/src/commands/init.mjs +2 -1
- package/src/commands/server.mjs +1 -4
- package/src/disclosure/content-scan.mjs +193 -0
- package/src/egress/check.mjs +7 -38
- package/src/egress/forbidden-egress.mjs +32 -18
- package/src/graph/graph.mjs +112 -314
- package/src/graph/knowledge-graph.mjs +94 -18
- package/src/harness/context-client.mjs +9 -1
- package/src/index.mjs +12 -0
- package/src/project/config.mjs +66 -7
- package/src/project/file-class.mjs +14 -0
- package/src/project/package-root.mjs +10 -0
- package/src/project/path-match.mjs +38 -15
- package/src/project/private-state.mjs +110 -0
- package/src/server/local-sidecar.mjs +81 -59
- package/src/server/security.mjs +89 -4
- package/src/server/server.mjs +3 -2
- package/src/support/feedback-report.mjs +4 -3
- package/src/upgrade/upgrade.mjs +2 -1
|
@@ -5,9 +5,17 @@ import fs from 'node:fs'
|
|
|
5
5
|
import http from 'node:http'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import {
|
|
9
|
+
atomicReplacePrivateText,
|
|
10
|
+
ensureContainedPrivateDirectory,
|
|
11
|
+
readRegularTextNoFollow,
|
|
12
|
+
} from '../project/private-state.mjs'
|
|
8
13
|
import {
|
|
9
14
|
expectedOriginForRequest,
|
|
10
15
|
htmlDocumentHeaders,
|
|
16
|
+
isLoopbackHost,
|
|
17
|
+
loadPublishedWorkspaceManifest,
|
|
18
|
+
publishedStaticPathVerdict,
|
|
11
19
|
requestHeader,
|
|
12
20
|
resolveWorkspacePath,
|
|
13
21
|
staticHeaders,
|
|
@@ -43,39 +51,37 @@ function nowMs() {
|
|
|
43
51
|
|
|
44
52
|
function readJsonFile(file, fallback) {
|
|
45
53
|
try {
|
|
46
|
-
return JSON.parse(
|
|
54
|
+
return JSON.parse(readRegularTextNoFollow(file))
|
|
47
55
|
} catch {
|
|
48
56
|
return fallback
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
function secureWriteJson(file, payload, mode = 0o600) {
|
|
53
|
-
|
|
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
|
-
}
|
|
61
|
+
atomicReplacePrivateText(file, `${JSON.stringify(payload, null, 2)}\n`, mode)
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
function readOrCreateNonce(noncePath) {
|
|
65
|
-
|
|
65
|
+
let existing = ''
|
|
66
|
+
if (fs.existsSync(noncePath)) {
|
|
67
|
+
const stat = fs.lstatSync(noncePath)
|
|
68
|
+
if (!stat.isFile()) throw new Error('nonce state leaf is not a regular file')
|
|
69
|
+
existing = readRegularTextNoFollow(noncePath).trim()
|
|
70
|
+
}
|
|
66
71
|
if (/^[a-f0-9]{64}$/.test(existing)) return existing
|
|
67
72
|
const nonce = crypto.randomBytes(32).toString('hex')
|
|
68
|
-
|
|
73
|
+
atomicReplacePrivateText(noncePath, `${nonce}\n`)
|
|
69
74
|
return nonce
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
function json(res, code, obj) {
|
|
77
|
+
function json(res, code, obj, headers = {}) {
|
|
73
78
|
const body = Buffer.from(`${JSON.stringify(obj, null, 2)}\n`)
|
|
74
79
|
res.writeHead(code, {
|
|
75
80
|
'Content-Type': 'application/json; charset=utf-8',
|
|
76
81
|
'Content-Length': String(body.length),
|
|
77
82
|
'Cache-Control': 'no-store',
|
|
78
83
|
'X-Content-Type-Options': 'nosniff',
|
|
84
|
+
...headers,
|
|
79
85
|
})
|
|
80
86
|
res.end(body)
|
|
81
87
|
}
|
|
@@ -205,16 +211,33 @@ export function createAtelierSidecarServer({
|
|
|
205
211
|
} = {}) {
|
|
206
212
|
const root = path.resolve(workspaceRoot)
|
|
207
213
|
const rootReal = fs.realpathSync(root)
|
|
214
|
+
const stateDirReal = ensureContainedPrivateDirectory({
|
|
215
|
+
workspaceRoot: root,
|
|
216
|
+
directory: stateDir,
|
|
217
|
+
label: 'Atelier state directory',
|
|
218
|
+
})
|
|
219
|
+
const publication = loadPublishedWorkspaceManifest(root)
|
|
208
220
|
const workspaceId = workspaceIdForRoot(root)
|
|
209
|
-
const noncePath = path.join(
|
|
210
|
-
const presencePath = path.join(
|
|
221
|
+
const noncePath = path.join(stateDirReal, '.atelier-nonce')
|
|
222
|
+
const presencePath = path.join(stateDirReal, '.atelier-presence.json')
|
|
211
223
|
const mutationNonce = readOrCreateNonce(noncePath)
|
|
212
224
|
const proposals = createProposalStore({
|
|
213
225
|
workspaceRoot: root,
|
|
214
|
-
proposalsDir: path.join(
|
|
226
|
+
proposalsDir: path.join(stateDirReal, '.atelier-proposals'),
|
|
215
227
|
workspaceId,
|
|
216
228
|
})
|
|
217
229
|
|
|
230
|
+
function resolvePublishedPath({ rel, requireFile = false, requireHtml = false } = {}) {
|
|
231
|
+
const resolved = resolveWorkspacePath({
|
|
232
|
+
workspaceRoot: root,
|
|
233
|
+
workspaceRootReal: rootReal,
|
|
234
|
+
rel,
|
|
235
|
+
requireFile,
|
|
236
|
+
requireHtml,
|
|
237
|
+
})
|
|
238
|
+
return publishedStaticPathVerdict({ resolved, publication })
|
|
239
|
+
}
|
|
240
|
+
|
|
218
241
|
function readPresence() {
|
|
219
242
|
const presence = readJsonFile(presencePath, defaultPresence())
|
|
220
243
|
return {
|
|
@@ -229,12 +252,7 @@ export function createAtelierSidecarServer({
|
|
|
229
252
|
}
|
|
230
253
|
|
|
231
254
|
function recordView(rel, hints = {}) {
|
|
232
|
-
const resolved =
|
|
233
|
-
workspaceRoot: root,
|
|
234
|
-
workspaceRootReal: rootReal,
|
|
235
|
-
rel,
|
|
236
|
-
requireHtml: true,
|
|
237
|
-
})
|
|
255
|
+
const resolved = resolvePublishedPath({ rel, requireHtml: true })
|
|
238
256
|
if (!resolved.ok) return { ok: false, error: 'unknown workspace html file' }
|
|
239
257
|
const entry = {
|
|
240
258
|
sessionId: cleanIdentity(hints.sessionId, 120),
|
|
@@ -295,12 +313,7 @@ export function createAtelierSidecarServer({
|
|
|
295
313
|
const viewId = cleanIdentity(url.searchParams.get('viewId'), 120)
|
|
296
314
|
const rel = cleanIdentity(url.searchParams.get('path') || 'index.html', 500)
|
|
297
315
|
const expectedWorkspaceId = cleanIdentity(url.searchParams.get('expectedWorkspaceId'), 120)
|
|
298
|
-
const resolved =
|
|
299
|
-
workspaceRoot: root,
|
|
300
|
-
workspaceRootReal: rootReal,
|
|
301
|
-
rel,
|
|
302
|
-
requireHtml: true,
|
|
303
|
-
})
|
|
316
|
+
const resolved = resolvePublishedPath({ rel, requireHtml: true })
|
|
304
317
|
const current = resolveCurrentContext({ sessionId })
|
|
305
318
|
const workspaceVerified = Boolean(expectedWorkspaceId && expectedWorkspaceId === workspaceId)
|
|
306
319
|
const caps = capabilityContract()
|
|
@@ -366,12 +379,7 @@ export function createAtelierSidecarServer({
|
|
|
366
379
|
}
|
|
367
380
|
|
|
368
381
|
if (url.pathname === '/api/proposals') {
|
|
369
|
-
const resolved =
|
|
370
|
-
workspaceRoot: root,
|
|
371
|
-
workspaceRootReal: rootReal,
|
|
372
|
-
rel: body.path || body.rel,
|
|
373
|
-
requireHtml: true,
|
|
374
|
-
})
|
|
382
|
+
const resolved = resolvePublishedPath({ rel: body.path || body.rel, requireHtml: true })
|
|
375
383
|
if (!resolved.ok) {
|
|
376
384
|
json(res, 200, { ok: false, error: 'unknown workspace html file' })
|
|
377
385
|
return
|
|
@@ -392,6 +400,7 @@ export function createAtelierSidecarServer({
|
|
|
392
400
|
schema: result.record.schema,
|
|
393
401
|
workspaceId,
|
|
394
402
|
proposal: result.record.proposal,
|
|
403
|
+
diagnostics: result.diagnostics,
|
|
395
404
|
})
|
|
396
405
|
return
|
|
397
406
|
}
|
|
@@ -416,6 +425,7 @@ export function createAtelierSidecarServer({
|
|
|
416
425
|
proposal: result.record.proposal,
|
|
417
426
|
diff: result.record.diff,
|
|
418
427
|
copyable: result.record.copyable,
|
|
428
|
+
diagnostics: result.diagnostics,
|
|
419
429
|
})
|
|
420
430
|
return
|
|
421
431
|
}
|
|
@@ -430,12 +440,7 @@ export function createAtelierSidecarServer({
|
|
|
430
440
|
}
|
|
431
441
|
|
|
432
442
|
const rel = url.pathname === '/' ? 'index.html' : url.pathname.slice(1)
|
|
433
|
-
const resolved =
|
|
434
|
-
workspaceRoot: root,
|
|
435
|
-
workspaceRootReal: rootReal,
|
|
436
|
-
rel,
|
|
437
|
-
requireFile: true,
|
|
438
|
-
})
|
|
443
|
+
const resolved = resolvePublishedPath({ rel, requireFile: true })
|
|
439
444
|
if (!resolved.ok) {
|
|
440
445
|
text(res, resolved.status || 403, `${resolved.error || 'forbidden'}\n`)
|
|
441
446
|
return
|
|
@@ -463,6 +468,16 @@ export function createAtelierSidecarServer({
|
|
|
463
468
|
return
|
|
464
469
|
}
|
|
465
470
|
|
|
471
|
+
if (!['GET', 'HEAD', 'POST'].includes(req.method || '')) {
|
|
472
|
+
json(res, 405, { ok: false, error: 'method not allowed' }, { Allow: 'GET, HEAD, POST' })
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!trustedReadRequest(req.headers, { expectedOrigin })) {
|
|
477
|
+
json(res, 403, { ok: false, error: 'cross-origin or unclassified request refused' })
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
|
|
466
481
|
if (req.method === 'POST') {
|
|
467
482
|
await handlePost(req, res, url)
|
|
468
483
|
return
|
|
@@ -478,12 +493,7 @@ export function createAtelierSidecarServer({
|
|
|
478
493
|
return
|
|
479
494
|
}
|
|
480
495
|
const rel = url.searchParams.get('path') || 'index.html'
|
|
481
|
-
const resolved =
|
|
482
|
-
workspaceRoot: root,
|
|
483
|
-
workspaceRootReal: rootReal,
|
|
484
|
-
rel,
|
|
485
|
-
requireHtml: true,
|
|
486
|
-
})
|
|
496
|
+
const resolved = resolvePublishedPath({ rel, requireHtml: true })
|
|
487
497
|
if (!resolved.ok) {
|
|
488
498
|
json(res, 404, { ok: false, error: 'unknown workspace html file' })
|
|
489
499
|
return
|
|
@@ -509,12 +519,7 @@ export function createAtelierSidecarServer({
|
|
|
509
519
|
return
|
|
510
520
|
}
|
|
511
521
|
if (url.pathname === '/api/resolve') {
|
|
512
|
-
const resolved =
|
|
513
|
-
workspaceRoot: root,
|
|
514
|
-
workspaceRootReal: rootReal,
|
|
515
|
-
rel: url.searchParams.get('path') || 'index.html',
|
|
516
|
-
requireHtml: true,
|
|
517
|
-
})
|
|
522
|
+
const resolved = resolvePublishedPath({ rel: url.searchParams.get('path') || 'index.html', requireHtml: true })
|
|
518
523
|
json(res, resolved.ok ? 200 : (resolved.status || 404), {
|
|
519
524
|
ok: resolved.ok,
|
|
520
525
|
schema: ATELIER_RESOLVE_SCHEMA,
|
|
@@ -533,37 +538,49 @@ export function createAtelierSidecarServer({
|
|
|
533
538
|
return
|
|
534
539
|
}
|
|
535
540
|
if (url.pathname === '/api/proposals') {
|
|
541
|
+
const listed = proposals.listProposals()
|
|
542
|
+
if (!listed.ok) {
|
|
543
|
+
json(res, listed.status, { ok: false, error: listed.error, diagnostics: listed.diagnostics })
|
|
544
|
+
return
|
|
545
|
+
}
|
|
536
546
|
json(res, 200, {
|
|
537
547
|
ok: true,
|
|
538
548
|
schema: 'atelier-proposals@v1',
|
|
539
549
|
workspaceId,
|
|
540
|
-
proposals: proposals
|
|
550
|
+
proposals: listed.proposals,
|
|
541
551
|
})
|
|
542
552
|
return
|
|
543
553
|
}
|
|
544
554
|
const proposalReadMatch = url.pathname.match(/^\/api\/proposals\/([^/]+)$/)
|
|
545
555
|
if (proposalReadMatch) {
|
|
546
|
-
const
|
|
547
|
-
|
|
556
|
+
const result = proposals.readProposal(proposalReadMatch[1])
|
|
557
|
+
const record = result.record
|
|
558
|
+
json(res, result.ok ? 200 : result.status, result.ok ? {
|
|
548
559
|
ok: true,
|
|
549
560
|
schema: record.schema,
|
|
550
561
|
workspaceId,
|
|
551
562
|
proposal: record.proposal,
|
|
552
563
|
diff: record.diff,
|
|
553
564
|
copyable: record.copyable,
|
|
554
|
-
} : { ok: false, error:
|
|
565
|
+
} : { ok: false, error: result.error, diagnostics: result.diagnostics })
|
|
555
566
|
return
|
|
556
567
|
}
|
|
557
568
|
if (url.pathname === '/proposals') {
|
|
558
|
-
const
|
|
569
|
+
const listed = proposals.listProposals()
|
|
570
|
+
if (!listed.ok) {
|
|
571
|
+
text(res, listed.status, `${listed.error}\n`)
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
const body = renderProposalListPageHtml(listed.proposals)
|
|
559
575
|
res.writeHead(200, htmlDocumentHeaders(Buffer.byteLength(body)))
|
|
560
576
|
res.end(body)
|
|
561
577
|
return
|
|
562
578
|
}
|
|
563
579
|
const proposalPageMatch = url.pathname.match(/^\/proposals\/([^/]+)$/)
|
|
564
580
|
if (proposalPageMatch) {
|
|
565
|
-
const
|
|
566
|
-
|
|
581
|
+
const result = proposals.readProposal(proposalPageMatch[1])
|
|
582
|
+
const body = renderProposalDetailPageHtml(result.record)
|
|
583
|
+
res.writeHead(result.ok ? 200 : result.status, htmlDocumentHeaders(Buffer.byteLength(body)))
|
|
567
584
|
res.end(body)
|
|
568
585
|
return
|
|
569
586
|
}
|
|
@@ -595,8 +612,12 @@ export function createAtelierSidecarServer({
|
|
|
595
612
|
workspaceRootReal: rootReal,
|
|
596
613
|
workspaceId,
|
|
597
614
|
mutationNonce,
|
|
615
|
+
publication,
|
|
598
616
|
proposals,
|
|
599
617
|
listen(listenPort = port, host = '127.0.0.1') {
|
|
618
|
+
if (!isLoopbackHost(host)) {
|
|
619
|
+
return Promise.reject(new Error(`non-loopback listen host refused: ${host}`))
|
|
620
|
+
}
|
|
600
621
|
// A busy port is an ordinary condition, not a crash: surface it as a
|
|
601
622
|
// rejection the CLI can print, instead of an unhandled 'error' event.
|
|
602
623
|
return new Promise((resolve, reject) => {
|
|
@@ -615,6 +636,7 @@ export function createAtelierSidecarServer({
|
|
|
615
636
|
})
|
|
616
637
|
},
|
|
617
638
|
close() {
|
|
639
|
+
if (!server.listening) return Promise.resolve()
|
|
618
640
|
return new Promise((resolve, reject) => {
|
|
619
641
|
server.close((error) => {
|
|
620
642
|
if (error) reject(error)
|
package/src/server/security.mjs
CHANGED
|
@@ -37,6 +37,9 @@ export const LOOPBACK_HOSTS = new Set([
|
|
|
37
37
|
'[::1]',
|
|
38
38
|
])
|
|
39
39
|
|
|
40
|
+
export const ATELIER_MANIFEST_SCHEMA = 'mnstry.atelier-manifest@v1'
|
|
41
|
+
export const PUBLISHED_STATIC_EXTENSIONS = new Set(['.html', '.js', '.mjs', '.css', '.svg', '.png', '.jpg', '.jpeg', '.webp'])
|
|
42
|
+
|
|
40
43
|
export function parseHostHeader(value) {
|
|
41
44
|
const raw = String(value || '').trim().toLowerCase()
|
|
42
45
|
if (!raw) return ''
|
|
@@ -66,7 +69,7 @@ export function expectedOriginForRequest(headers, fallbackPort = null) {
|
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
export function sameOrigin(origin, expectedOrigin) {
|
|
69
|
-
if (!origin) return
|
|
72
|
+
if (!origin) return false
|
|
70
73
|
if (!expectedOrigin) return false
|
|
71
74
|
try {
|
|
72
75
|
return new URL(origin).origin === new URL(expectedOrigin).origin
|
|
@@ -77,7 +80,7 @@ export function sameOrigin(origin, expectedOrigin) {
|
|
|
77
80
|
|
|
78
81
|
export function trustedFetchSite(headers) {
|
|
79
82
|
const value = requestHeader(headers, 'sec-fetch-site').toLowerCase()
|
|
80
|
-
return value === '
|
|
83
|
+
return value === 'none' || value === 'same-origin'
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
export function trustedHost(headers) {
|
|
@@ -85,11 +88,92 @@ export function trustedHost(headers) {
|
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
export function trustedReadRequest(headers, { expectedOrigin = expectedOriginForRequest(headers) } = {}) {
|
|
88
|
-
|
|
91
|
+
const origin = requestHeader(headers, 'origin')
|
|
92
|
+
return trustedHost(headers) && trustedFetchSite(headers) && (!origin || sameOrigin(origin, expectedOrigin))
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
export function trustedMutationRequest(headers, { expectedOrigin = expectedOriginForRequest(headers) } = {}) {
|
|
92
|
-
|
|
96
|
+
const origin = requestHeader(headers, 'origin')
|
|
97
|
+
return Boolean(origin) && trustedReadRequest(headers, { expectedOrigin }) && sameOrigin(origin, expectedOrigin)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function manifestPathValue(value) {
|
|
101
|
+
if (typeof value === 'string') return value
|
|
102
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
|
|
103
|
+
return value.path || value.output || value.href || ''
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function secretShapedPath(value) {
|
|
107
|
+
return /(?:^|[._-])(?:secret|credential|token|password|nonce|private[._-]?key)(?:$|[._-])/i.test(value)
|
|
108
|
+
|| /(?:^|\/)\.env(?:\.|$)/i.test(value)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function addManifestPath(paths, value, label) {
|
|
112
|
+
const candidate = manifestPathValue(value)
|
|
113
|
+
const normalized = normalizeWorkspacePath(candidate, { defaultFile: '' })
|
|
114
|
+
if (!candidate || !normalized.ok || /^[a-z][a-z0-9+.-]*:/i.test(candidate)) {
|
|
115
|
+
throw new Error(`atelier.manifest.json ${label} must be a workspace-relative path`)
|
|
116
|
+
}
|
|
117
|
+
if (deniedStaticStatePath(normalized.path) || normalized.path.split('/').some((segment) => segment.startsWith('.')) || secretShapedPath(normalized.path)) {
|
|
118
|
+
throw new Error(`atelier.manifest.json ${label} enrolls a hidden, state, or secret-shaped path`)
|
|
119
|
+
}
|
|
120
|
+
if (!PUBLISHED_STATIC_EXTENSIONS.has(path.extname(normalized.path).toLowerCase())) {
|
|
121
|
+
throw new Error(`atelier.manifest.json ${label} enrolls an unsupported static file type`)
|
|
122
|
+
}
|
|
123
|
+
paths.add(normalized.path)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function loadPublishedWorkspaceManifest(workspaceRoot) {
|
|
127
|
+
const root = path.resolve(workspaceRoot)
|
|
128
|
+
const rootReal = fs.realpathSync(root)
|
|
129
|
+
const manifestPath = path.join(root, 'atelier.manifest.json')
|
|
130
|
+
if (!fs.existsSync(manifestPath)) throw new Error('served workspace must contain generated atelier.manifest.json')
|
|
131
|
+
const manifestReal = fs.realpathSync(manifestPath)
|
|
132
|
+
if (!pathContainedBy(rootReal, manifestReal)) throw new Error('atelier.manifest.json realpath escapes served workspace')
|
|
133
|
+
let manifest
|
|
134
|
+
try {
|
|
135
|
+
manifest = JSON.parse(fs.readFileSync(manifestReal, 'utf8'))
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new Error(`atelier.manifest.json must be valid JSON: ${error.message}`)
|
|
138
|
+
}
|
|
139
|
+
if (manifest?.schema !== ATELIER_MANIFEST_SCHEMA) {
|
|
140
|
+
throw new Error(`atelier.manifest.json schema must be ${ATELIER_MANIFEST_SCHEMA}`)
|
|
141
|
+
}
|
|
142
|
+
const paths = new Set()
|
|
143
|
+
if (typeof manifest.entry !== 'string') throw new Error('atelier.manifest.json entry must be a workspace-relative HTML path')
|
|
144
|
+
addManifestPath(paths, manifest.entry, 'entry')
|
|
145
|
+
if (path.extname(manifest.entry).toLowerCase() !== '.html') throw new Error('atelier.manifest.json entry must be an HTML file')
|
|
146
|
+
for (const key of ['files', 'assets']) {
|
|
147
|
+
if (manifest[key] == null) continue
|
|
148
|
+
if (!Array.isArray(manifest[key])) throw new Error(`atelier.manifest.json ${key} must be an array`)
|
|
149
|
+
for (const [index, value] of manifest[key].entries()) addManifestPath(paths, value, `${key}[${index}]`)
|
|
150
|
+
}
|
|
151
|
+
for (const rel of paths) {
|
|
152
|
+
const resolved = resolveWorkspacePath({ workspaceRoot: root, workspaceRootReal: rootReal, rel, requireFile: true })
|
|
153
|
+
if (!resolved.ok) throw new Error(`atelier.manifest.json enrolled path is unavailable: ${rel}`)
|
|
154
|
+
const realRel = path.relative(rootReal, resolved.real).replaceAll('\\', '/')
|
|
155
|
+
if (realRel !== rel) throw new Error(`atelier.manifest.json enrolled path must not be a symlink: ${rel}`)
|
|
156
|
+
}
|
|
157
|
+
return Object.freeze({ manifest, manifestPath: manifestReal, paths })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function publishedStaticPathVerdict({ resolved, publication } = {}) {
|
|
161
|
+
if (!resolved?.ok) return resolved
|
|
162
|
+
const realRel = path.relative(resolved.workspaceRootReal, resolved.real).replaceAll('\\', '/')
|
|
163
|
+
const segments = realRel.split('/')
|
|
164
|
+
if (segments.some((segment) => segment.startsWith('.')) || deniedStaticStatePath(realRel)) {
|
|
165
|
+
return { ok: false, status: 403, error: 'hidden or local state path is not published', path: resolved.path }
|
|
166
|
+
}
|
|
167
|
+
if (realRel === 'atelier.manifest.json' || secretShapedPath(realRel)) {
|
|
168
|
+
return { ok: false, status: 403, error: 'secret-shaped path is not published', path: resolved.path }
|
|
169
|
+
}
|
|
170
|
+
if (!PUBLISHED_STATIC_EXTENSIONS.has(path.extname(realRel).toLowerCase())) {
|
|
171
|
+
return { ok: false, status: 403, error: 'static file type is not published', path: resolved.path }
|
|
172
|
+
}
|
|
173
|
+
if (!publication?.paths?.has(realRel)) {
|
|
174
|
+
return { ok: false, status: 404, error: 'path is not enrolled by atelier.manifest.json', path: resolved.path }
|
|
175
|
+
}
|
|
176
|
+
return resolved
|
|
93
177
|
}
|
|
94
178
|
|
|
95
179
|
export function pathContainedBy(root, candidate) {
|
|
@@ -191,6 +275,7 @@ export function resolveWorkspacePath({
|
|
|
191
275
|
real,
|
|
192
276
|
stat,
|
|
193
277
|
workspaceContained: true,
|
|
278
|
+
workspaceRootReal,
|
|
194
279
|
}
|
|
195
280
|
}
|
|
196
281
|
|
package/src/server/server.mjs
CHANGED
|
@@ -26,11 +26,12 @@ export async function runServerCommand(argv = process.argv.slice(2)) {
|
|
|
26
26
|
if (args.smoke) {
|
|
27
27
|
const address = await sidecar.listen()
|
|
28
28
|
const base = `http://127.0.0.1:${address.port}`
|
|
29
|
+
const headers = { Origin: base, 'Sec-Fetch-Site': 'same-origin' }
|
|
29
30
|
// @atelier-egress-local-computed
|
|
30
|
-
const health = await fetch(`${base}/api/health
|
|
31
|
+
const health = await fetch(`${base}/api/health`, { headers }).then((res) => res.json())
|
|
31
32
|
if (!health.ok) throw new Error('health check failed')
|
|
32
33
|
// @atelier-egress-local-computed
|
|
33
|
-
const page = await fetch(`${base}
|
|
34
|
+
const page = await fetch(`${base}/`, { headers }).then((res) => res.text())
|
|
34
35
|
if (!page.includes('<meta name="mnstry:atelier"')) throw new Error('projection smoke failed')
|
|
35
36
|
await sidecar.close()
|
|
36
37
|
console.log('[atelier:browser:smoke] local projection and health endpoint passed')
|
|
@@ -56,7 +56,7 @@ const USAGE = `Usage: atelier feedback <subcommand>
|
|
|
56
56
|
Subcommands:
|
|
57
57
|
create --message TEXT | --message-file PATH [--context FILE] [--include-gates]
|
|
58
58
|
Assemble a local feedback report from your own words and write it to
|
|
59
|
-
${LOCAL_STATE_DIR}/feedback/<hash>.json (mode 0600). This is the
|
|
59
|
+
${LOCAL_STATE_DIR}/feedback/<hash>.json (mode 0600 on POSIX). This is the
|
|
60
60
|
default subcommand. The report is first walked with the support-bundle
|
|
61
61
|
banned key and value patterns; if anything matches, nothing is written
|
|
62
62
|
and the refusal names the pattern label and location only.
|
|
@@ -202,8 +202,9 @@ export function buildFeedbackReport({
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
export function writeFeedbackReport(payload, { baseDir = process.cwd() } = {}) {
|
|
205
|
-
const
|
|
206
|
-
const
|
|
205
|
+
const nativeRelative = path.join(LOCAL_STATE_DIR, 'feedback', `${feedbackReportHash(payload)}.json`)
|
|
206
|
+
const relative = nativeRelative.split(path.sep).join('/')
|
|
207
|
+
const file = path.join(baseDir, nativeRelative)
|
|
207
208
|
// "Local state" is only local if the directory it lands in is ignored, and
|
|
208
209
|
// feedback writes wherever the user happens to stand rather than in a
|
|
209
210
|
// configured workspace. Same git check-ignore test the project chassis runs
|
package/src/upgrade/upgrade.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolvePathValue,
|
|
15
15
|
writeJson,
|
|
16
16
|
} from '../project/config.mjs'
|
|
17
|
+
import { packageRootFrom } from '../project/package-root.mjs'
|
|
17
18
|
import {
|
|
18
19
|
BOUNDARY_POLICY_SCHEMA,
|
|
19
20
|
checkBoundaryPolicy,
|
|
@@ -36,7 +37,7 @@ export const MIGRATION_CLASSES = new Set([
|
|
|
36
37
|
'breaking',
|
|
37
38
|
])
|
|
38
39
|
|
|
39
|
-
const packageRoot =
|
|
40
|
+
const packageRoot = packageRootFrom(import.meta.url)
|
|
40
41
|
const packageJson = readJson(path.join(packageRoot, 'package.json'))
|
|
41
42
|
const lockSchema = readJson(path.join(packageRoot, 'contracts', 'atelier-lock.v1.schema.json'))
|
|
42
43
|
const REVIEW_MARKER_RE = /(Atelier-Boundary-Review|boundary-review)\s*:\s*(approved|reviewed)/i
|