@aotter/mantle 0.1.2-alpha.6 → 0.1.2
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/README.md +15 -10
- package/dist/cli/generate.d.ts +9 -0
- package/dist/cli/generate.d.ts.map +1 -1
- package/dist/cli/generate.js +40 -1
- package/dist/cli/generate.js.map +1 -1
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +37 -9
- package/dist/cli/main.js.map +1 -1
- package/docs/adapter-guide.md +6 -1
- package/docs/adr/adr-lite-845-frontend-client.md +38 -0
- package/docs/agent-prompts.md +91 -0
- package/docs/api-mcp-authorization.md +1 -1
- package/docs/auth-hosting-model.md +1 -1
- package/docs/examples/README.md +22 -0
- package/docs/examples/builtin-commerce.md +269 -0
- package/docs/examples/builtin-intake.md +143 -0
- package/docs/examples/builtin-legal-documents.md +189 -0
- package/docs/examples/builtin-procurement.md +241 -0
- package/docs/examples/builtin-publication.md +241 -0
- package/docs/examples/builtin-reservation.md +149 -0
- package/docs/examples/cf-primitives-commerce-inventory.md +809 -0
- package/docs/examples/cf-primitives-guarded-api.md +429 -0
- package/docs/examples/cf-primitives-intake-hooks.md +319 -0
- package/docs/examples/host-chatgpt-sites/.openai/hosting.json +1 -0
- package/docs/examples/host-chatgpt-sites/README.md +53 -0
- package/docs/examples/host-chatgpt-sites/drizzle/0000_sites_users.sql +8 -0
- package/docs/examples/host-chatgpt-sites/drizzle/0001_mantle.sql +294 -0
- package/docs/examples/host-chatgpt-sites/drizzle/0002_article_cover.sql +5 -0
- package/docs/examples/host-chatgpt-sites/drizzle/meta/_journal.json +1 -0
- package/docs/examples/host-chatgpt-sites/manifests/site.yaml +27 -0
- package/docs/examples/host-chatgpt-sites/package.json +1 -0
- package/docs/examples/host-chatgpt-sites/public/site.css +1 -0
- package/docs/examples/host-chatgpt-sites/scripts/build.mjs +12 -0
- package/docs/examples/host-chatgpt-sites/scripts/check.mjs +99 -0
- package/docs/examples/host-chatgpt-sites/scripts/migration.mjs +9 -0
- package/docs/examples/host-chatgpt-sites/src/chatgpt-auth.ts +63 -0
- package/docs/examples/host-chatgpt-sites/src/index.ts +46 -0
- package/docs/examples/host-chatgpt-sites/src/mcp.ts +49 -0
- package/docs/examples/host-chatgpt-sites/src/media.ts +109 -0
- package/docs/examples/host-chatgpt-sites/src/r2-lab.ts +38 -0
- package/docs/examples/host-chatgpt-sites/src/storage-fingerprint.json +1 -0
- package/docs/examples/host-chatgpt-sites/src/web.ts +35 -0
- package/docs/examples/host-chatgpt-sites/tsconfig.json +1 -0
- package/docs/examples/host-chatgpt-sites/wrangler.jsonc +10 -0
- package/docs/examples/host-local-admin-otp/.dev.vars.example +3 -0
- package/docs/examples/host-local-admin-otp/README.md +70 -0
- package/docs/examples/host-local-admin-otp/ensure-dev-vars.mjs +5 -0
- package/docs/examples/host-local-admin-otp/package.json +29 -0
- package/docs/examples/host-local-admin-otp/public/.gitkeep +1 -0
- package/docs/examples/host-local-admin-otp/smoke.mjs +141 -0
- package/docs/examples/host-local-admin-otp/src/index.ts +54 -0
- package/docs/examples/host-local-admin-otp/wrangler.jsonc +23 -0
- package/docs/examples/{minimal-worker → host-minimal-worker}/README.md +9 -5
- package/docs/examples/host-minimal-worker/manifests/site.yaml +25 -0
- package/docs/examples/{minimal-worker → host-minimal-worker}/package.json +3 -3
- package/docs/examples/host-minimal-worker/tsconfig.json +17 -0
- package/docs/handbook/cloudflare/authentication.md +17 -2
- package/docs/handbook/cloudflare/bindings.md +9 -7
- package/docs/handbook/cloudflare/chatgpt-sites.md +29 -0
- package/docs/handbook/cloudflare/conventional-worker.md +3 -3
- package/docs/handbook/cloudflare/deferred-hooks-queues.md +0 -1
- package/docs/handbook/cloudflare/deploy-and-operate.md +14 -20
- package/docs/handbook/cloudflare/media-r2.md +2 -2
- package/docs/handbook/cloudflare/public-web.md +1 -1
- package/docs/handbook/cloudflare/site-chrome.md +75 -0
- package/docs/handbook/concepts/authorization.md +2 -2
- package/docs/handbook/concepts/four-atoms.md +2 -2
- package/docs/handbook/concepts/lifecycle-and-locales.md +1 -1
- package/docs/handbook/concepts/mcp-and-agents.md +1 -1
- package/docs/handbook/concepts/procedures-and-triggers.md +1 -1
- package/docs/handbook/concepts/runtime-and-adapters.md +2 -2
- package/docs/handbook/concepts/views.md +2 -2
- package/docs/handbook/examples/commerce-transaction.md +4 -806
- package/docs/handbook/examples/commerce.md +11 -0
- package/docs/handbook/examples/guarded-api.md +3 -420
- package/docs/handbook/examples/hub.md +10 -0
- package/docs/handbook/examples/intake-form.md +6 -313
- package/docs/handbook/examples/intake-hooks.md +11 -0
- package/docs/handbook/examples/legal-documents.md +3 -211
- package/docs/handbook/examples/procurement-approvals.md +3 -233
- package/docs/handbook/examples/publication.md +3 -233
- package/docs/handbook/examples/reservation.md +3 -213
- package/docs/handbook/navigation.json +17 -2
- package/docs/handbook/reference/authorization.md +1 -1
- package/docs/handbook/reference/procedure.md +2 -2
- package/docs/handbook/reference/schema.md +3 -3
- package/docs/handbook/reference/site-config.md +5 -16
- package/docs/handbook/reference/surface.md +3 -7
- package/docs/handbook/sites/equipment-checkout.md +231 -0
- package/docs/handbook/sites/host-reference.md +113 -0
- package/docs/handbook/sites/index.md +111 -0
- package/docs/handbook/start/project-and-cli.md +22 -14
- package/docs/handbook/start/quickstart-admin.md +239 -0
- package/docs/handbook/start/quickstart-worker.md +22 -23
- package/docs/migration-0.1.2.md +26 -0
- package/docs/release-process.md +92 -7
- package/docs/sealed-pipeline-ownership.md +2 -2
- package/docs/spec-only-host-adoption.md +3 -4
- package/docs/transaction-patterns.md +2 -2
- package/package.json +15 -15
- package/skills/README.md +18 -2
- package/skills/develop/SKILL.md +32 -23
- package/skills/install/SKILL.md +50 -11
- package/skills/provision/SKILL.md +21 -5
- /package/docs/examples/{minimal-worker → host-local-admin-otp}/manifests/site.yaml +0 -0
- /package/docs/examples/{minimal-worker → host-local-admin-otp}/tsconfig.json +0 -0
- /package/docs/examples/{minimal-worker → host-minimal-worker}/smoke.mjs +0 -0
- /package/docs/examples/{minimal-worker → host-minimal-worker}/src/index.ts +0 -0
- /package/docs/examples/{minimal-worker → host-minimal-worker}/wrangler.jsonc +0 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
ALTER TABLE "articles" ADD COLUMN "coverAssetId" TEXT;
|
|
2
|
+
--> statement-breakpoint
|
|
3
|
+
UPDATE _mantle_schema_tables SET projection='{"columns":[["body","TEXT","string",false],["coverAssetId","TEXT","string",false],["summary","TEXT","string",false],["title","TEXT","string",false]],"indexes":[]}' WHERE name='articles';
|
|
4
|
+
--> statement-breakpoint
|
|
5
|
+
UPDATE _mantle_storage_state SET fingerprint='9ef85f9933e22328140e924045820af6be2a6985ed8ecc19b63a4f2a37826618' WHERE id=1 AND fingerprint='e3cdedf91dbe27db4b9af7ff7e3512e81ae0b0bd69e5a90bd1c9e0526d86ba6b';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"7","dialect":"sqlite","entries":[{"idx":0,"version":"6","when":1790000000000,"tag":"0000_sites_users","breakpoints":true},{"idx":1,"version":"6","when":1790000000001,"tag":"0001_mantle","breakpoints":true},{"idx":2,"version":"6","when":1790000000002,"tag":"0002_article_cover","breakpoints":true}]}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
2
|
+
kind: Schema
|
|
3
|
+
metadata:
|
|
4
|
+
name: articles
|
|
5
|
+
spec:
|
|
6
|
+
title: 文章
|
|
7
|
+
schema:
|
|
8
|
+
type: object
|
|
9
|
+
required: [title]
|
|
10
|
+
properties:
|
|
11
|
+
title: { type: string, title: 標題, maxLength: 160 }
|
|
12
|
+
summary: { type: string, title: 摘要, maxLength: 1000 }
|
|
13
|
+
body: { type: string, title: 內容, x-mcp-hint: markdown }
|
|
14
|
+
coverAssetId: { type: string, title: 封面圖片, x-mantle-ref: media_assets, x-mcp-hint: media-image }
|
|
15
|
+
lifecycle: publishing
|
|
16
|
+
---
|
|
17
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
18
|
+
kind: View
|
|
19
|
+
metadata:
|
|
20
|
+
name: published-articles
|
|
21
|
+
spec:
|
|
22
|
+
surface: public
|
|
23
|
+
from: articles
|
|
24
|
+
fields: [id, title, summary]
|
|
25
|
+
filter:
|
|
26
|
+
eq: { field: status, value: published }
|
|
27
|
+
limit: 20
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"mantle-sites-reference","version":"0.0.1","private":true,"type":"module","scripts":{"build":"node scripts/build.mjs","dev":"wrangler dev","generate":"mantle generate","check":"tsc --noEmit","test":"node --experimental-strip-types scripts/check.mjs"},"dependencies":{"@aotter/mantle":"latest","@aotter/mantle-admin":"latest","@aotter/mantle-admin-ui":"latest","@aotter/mantle-cloudflare":"latest","@aotter/mantle-web":"latest","hono":"^4.9.0","micromark":"4.0.2","zod":"^4.0.0"},"devDependencies":{"@cloudflare/workers-types":"*","esbuild":"^0.28.0","typescript":"^5.9.0","wrangler":"^4.0.0"}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{font-family:Inter,"Noto Sans TC",ui-sans-serif,system-ui,sans-serif;color:#172320;background:#f7f8f4;font-synthesis:none}*{box-sizing:border-box}body{margin:0}a{color:inherit;text-decoration:none}a:hover{text-decoration:underline}.topbar{height:78px;max-width:1440px;margin:auto;padding:0 clamp(24px,5vw,80px);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #d9dfd8}.brand{display:flex;align-items:center;gap:12px;font-size:18px;font-weight:760;letter-spacing:-.04em}.brand em{color:#95aa8c;font-style:normal}.mark{width:37px;height:37px;border-radius:11px;background:#183c30;color:#d9eadb;display:grid;place-items:center;font-size:19px;font-family:Georgia,serif}.topbar nav{display:flex;gap:28px;font-size:14px;color:#43564e}.hero{max-width:1440px;margin:auto;padding:100px clamp(24px,8vw,128px) 116px}.hero.small{padding-bottom:75px}.eyebrow,.feature-index{font-size:11px;letter-spacing:.19em;font-weight:750;color:#68806f}.hero h1,.article-page h1{font-family:Georgia,"Noto Serif TC",serif;font-weight:500;letter-spacing:-.055em;line-height:1.09;margin:26px 0 28px;font-size:clamp(48px,6.1vw,94px)}.dot{color:#a2b888}.hero-copy{max-width:620px;color:#52665a;font-size:18px;line-height:1.8}.actions{display:flex;align-items:center;gap:30px;margin-top:38px}.button{display:inline-flex;justify-content:space-between;gap:44px;min-width:184px;padding:17px 19px;border-radius:7px;color:#fff;background:#214f3c;font-size:14px;font-weight:650}.button:hover{background:#173c2d;text-decoration:none}.text-link{font-size:14px;color:#315640}.feature-grid{max-width:1440px;margin:auto;padding:0 clamp(24px,8vw,128px) 110px;display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.feature-grid>div{border-top:1px solid #bfcac0;padding-top:24px}.feature-grid h2{font-family:Georgia,"Noto Serif TC",serif;font-size:29px;font-weight:500;margin:29px 0 10px}.feature-grid p{font-size:14px;line-height:1.75;color:#597064;max-width:270px}.content-wrap{max-width:1184px;margin:0 auto;padding:0 24px 100px}.article-row{padding:25px 0;border-top:1px solid #cbd5ca;display:grid;grid-template-columns:160px 1fr 36px;gap:24px;align-items:start}.article-row:last-child{border-bottom:1px solid #cbd5ca}.article-number{font-size:12px;color:#708477;margin-top:11px}.article-row h2{font-family:Georgia,"Noto Serif TC",serif;font-size:clamp(24px,3vw,35px);font-weight:500;letter-spacing:-.035em;margin:0 0 8px}.article-row p{font-size:14px;color:#617468;line-height:1.65;margin:0}.article-row>span{color:#739279;font-size:25px}.empty{min-height:220px;padding:52px 0}.empty h2{font-size:27px;font-family:Georgia,"Noto Serif TC",serif;font-weight:500}.empty p{color:#66796c}.pagination{margin:26px 0}.article-page{max-width:900px;padding:75px 30px 140px;margin:auto}.back{font-size:13px;color:#5c7768}.article-page article{margin-top:75px}.article-page h1{font-size:clamp(44px,5vw,75px)}.lede{font-size:21px;line-height:1.75;color:#52685a}.prose{border-top:1px solid #cbd5ca;margin-top:45px;padding-top:28px;line-height:1.95;font-size:17px;color:#33473c}.prose p{margin:0 0 1.2em}.markdown-link{display:inline-block;margin-top:40px;font-size:13px;color:#4f765b}footer{border-top:1px solid #d5ddd3;max-width:1440px;margin:auto;padding:28px clamp(24px,5vw,80px);display:flex;justify-content:space-between;font-size:12px;color:#788b7d}@media(max-width:700px){.topbar{height:68px}.topbar nav{gap:16px;font-size:12px}.hero{padding-top:74px;padding-bottom:70px}.hero-copy{font-size:16px}.feature-grid{grid-template-columns:1fr;gap:26px}.article-row{grid-template-columns:1fr 26px;gap:10px}.article-number{grid-column:1/-1;margin:0}.article-page{padding-top:55px}.article-page article{margin-top:50px}footer{gap:20px}}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { build } from 'esbuild';
|
|
2
|
+
import { cp, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
execFileSync('node',['node_modules/@aotter/mantle/dist/cli/main.js','generate'],{stdio:'inherit'});
|
|
5
|
+
// Reject older published SDKs that cannot advertise the mounted MCP endpoints.
|
|
6
|
+
execFileSync('node',['node_modules/typescript/bin/tsc','--noEmit'],{stdio:'inherit'});
|
|
7
|
+
await mkdir('dist/server',{recursive:true});
|
|
8
|
+
await build({entryPoints:['src/index.ts'],outfile:'dist/server/index.js',bundle:true,format:'esm',platform:'browser',conditions:['workerd'],target:'es2022',external:['cloudflare:*','node:*'],minify:true});
|
|
9
|
+
await cp('public','dist/client',{recursive:true});
|
|
10
|
+
await mkdir('dist/.openai',{recursive:true});
|
|
11
|
+
await cp('.openai/hosting.json','dist/.openai/hosting.json');
|
|
12
|
+
await cp('drizzle','dist/.openai/drizzle',{recursive:true});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
// Only localhost: these headers simulate Sites' trusted dispatcher, never real SIWC.
|
|
3
|
+
const base=process.env.MANTLE_TEST_ORIGIN??'http://localhost:4174';
|
|
4
|
+
assert.ok(['localhost','127.0.0.1'].includes(new URL(base).hostname),'Smoke identity headers are local-only');
|
|
5
|
+
const owner={'oai-authenticated-user-id':'local-owner','oai-authenticated-user-email':'owner@example.test'};
|
|
6
|
+
const member={'oai-authenticated-user-id':`test-${Date.now()}`,'oai-authenticated-user-email':`test-${Date.now()}@example.test`};
|
|
7
|
+
const call=(path,headers={},method='GET',body)=>fetch(base+path,{redirect:'manual',method,headers:{...headers,...(body?{'content-type':'application/json',origin:base}:{})},body:body?JSON.stringify(body):undefined});
|
|
8
|
+
assert.equal((await call('/health')).status,200);
|
|
9
|
+
const mcp=(method,params)=>fetch(base+'/api/mcp',{method:'POST',headers:{'content-type':'application/json','mcp-protocol-version':'2025-11-25'},body:JSON.stringify({jsonrpc:'2.0',id:1,method,...(params?{params}:{})})});
|
|
10
|
+
const staffMcp=(headers,method,params)=>fetch(base+'/api/mcp/staff',{method:'POST',headers:{...headers,'content-type':'application/json','mcp-protocol-version':'2025-11-25'},body:JSON.stringify({jsonrpc:'2.0',id:2,method,...(params?{params}:{})})});
|
|
11
|
+
let mcpResponse=await mcp('initialize');assert.equal(mcpResponse.status,200,await mcpResponse.clone().text());
|
|
12
|
+
assert.equal((await mcpResponse.json()).result.serverInfo.name,'aotter.mantle.public');
|
|
13
|
+
mcpResponse=await mcp('tools/list');assert.equal(mcpResponse.status,200,await mcpResponse.clone().text());
|
|
14
|
+
const publicTools=(await mcpResponse.json()).result.tools.map(tool=>tool.name);
|
|
15
|
+
assert.deepEqual(publicTools,['query_view_published_articles']);
|
|
16
|
+
assert.equal((await call('/api/mcp')).status,405);
|
|
17
|
+
assert.equal((await call('/admin/api/me')).status,401);
|
|
18
|
+
assert.equal((await call('/admin/api/me',{'oai-authenticated-user-email':'owner@example.test'})).status,401);
|
|
19
|
+
assert.equal((await call('/admin/api/me',member)).status,403);
|
|
20
|
+
assert.equal((await (await call('/admin/api/me',owner)).json()).role,'owner');
|
|
21
|
+
const siteResponse=await call('/admin/api/site',owner);
|
|
22
|
+
assert.equal(siteResponse.status,200);
|
|
23
|
+
const site=await siteResponse.json();
|
|
24
|
+
assert.deepEqual(site.mcpEndpoints,{public:base+'/api/mcp',staff:base+'/api/mcp/staff'});
|
|
25
|
+
assert.equal(site.mcpUrl,base+'/api/mcp/staff');
|
|
26
|
+
assert.equal((await staffMcp({},'initialize')).status,401);
|
|
27
|
+
assert.equal((await staffMcp(member,'initialize')).status,403);
|
|
28
|
+
let staffResponse=await staffMcp(owner,'initialize');assert.equal(staffResponse.status,200,await staffResponse.clone().text());
|
|
29
|
+
assert.equal((await staffResponse.json()).result.serverInfo.name,'aotter.mantle.staff');
|
|
30
|
+
staffResponse=await staffMcp(owner,'tools/list');assert.equal(staffResponse.status,200,await staffResponse.clone().text());
|
|
31
|
+
assert.ok((await staffResponse.json()).result.tools.some(tool=>tool.name==='create_draft_articles'));
|
|
32
|
+
for(const path of ['/mcp','/mcp/staff'])assert.equal((await call(path,{},'POST',{jsonrpc:'2.0',id:1,method:'initialize'})).status,404);
|
|
33
|
+
assert.equal((await call('/admin/sign-in')).headers.get('location'),'/signin-with-chatgpt?return_to=%2Fadmin');
|
|
34
|
+
assert.equal((await call('/admin',owner)).status,200);
|
|
35
|
+
assert.equal((await call('/admin/lab/r2',member,'POST',{})).status,403);
|
|
36
|
+
assert.equal((await call('/admin/lab/r2',{},'POST',{})).status,401);
|
|
37
|
+
const probe=await call('/admin/lab/r2',owner,'POST',{});assert.equal(probe.status,200);
|
|
38
|
+
const probeId=(await probe.json()).id;
|
|
39
|
+
assert.equal((await (await call('/admin/lab/r2/'+probeId,owner)).json()).matches,true);
|
|
40
|
+
assert.equal((await (await call('/admin/lab/r2/'+probeId,owner,'DELETE',{})).json()).deleted,true);
|
|
41
|
+
assert.equal((await call('/admin/lab/r2/'+probeId,owner)).status,404);
|
|
42
|
+
const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/lXcAAAAASUVORK5CYII=','base64');
|
|
43
|
+
const short=await call('/admin/api/media/uploads',owner,'POST',{filename:'short.png',purpose:'content',variants:[{mimeType:'image/png',byteSize:1,role:'primary'}]});
|
|
44
|
+
assert.equal(short.status,200);
|
|
45
|
+
const shortUpload=(await short.json()).capabilities[0].uploadUrl;
|
|
46
|
+
assert.equal((await fetch(shortUpload,{method:'PUT',headers:{...owner,origin:base,'content-type':'image/png'},body:png})).status,413);
|
|
47
|
+
let media=await call('/admin/api/media/uploads',owner,'POST',{filename:'test.png',purpose:'content',variants:[{mimeType:'image/png',byteSize:png.length,role:'primary'}]});
|
|
48
|
+
assert.equal(media.status,200,await media.clone().text());
|
|
49
|
+
const upload=await media.json();
|
|
50
|
+
const putUrl=upload.capabilities[0].uploadUrl;
|
|
51
|
+
assert.equal((await fetch(putUrl,{method:'PUT',headers:{origin:base,'content-type':'image/png'},body:png})).status,401);
|
|
52
|
+
assert.equal((await fetch(putUrl,{method:'PUT',headers:{...owner,origin:'https://evil.example','content-type':'image/png'},body:png})).status,403);
|
|
53
|
+
media=await fetch(putUrl,{method:'PUT',headers:{...owner,origin:base,'content-type':'image/png'},body:png});
|
|
54
|
+
assert.equal(media.status,204,await media.clone().text());
|
|
55
|
+
const mediaUrl=`/media/content/${upload.uploadGroupId}/primary.png`;
|
|
56
|
+
assert.equal((await call(mediaUrl)).status,404);
|
|
57
|
+
media=await call(`/admin/api/media/uploads/${upload.uploadGroupId}/commit`,owner,'POST',{});
|
|
58
|
+
assert.equal(media.status,200,await media.clone().text());
|
|
59
|
+
assert.deepEqual(Buffer.from(await (await call(mediaUrl)).arrayBuffer()),png);
|
|
60
|
+
assert.equal((await call('/admin/api/media',owner)).status,200);
|
|
61
|
+
let r=await call('/admin/api/entries',owner,'POST',{collection:'articles',data:{title:'Smoke test',summary:'D1 persistence',body:'## Published body from Mantle\n\n[unsafe](javascript:alert(1))',coverAssetId:upload.uploadGroupId}});
|
|
62
|
+
assert.equal(r.status,200,await r.clone().text());
|
|
63
|
+
const {entry}=await r.json();
|
|
64
|
+
const path=`/admin/api/entries/${entry.id}?collection=articles`;
|
|
65
|
+
assert.equal((await call(`/articles/${entry.id}`)).status,404);
|
|
66
|
+
assert.equal((await call(`/articles/${entry.id}.md`)).status,404);
|
|
67
|
+
r=await call(path,owner);assert.equal((await r.json()).entry.data.title,'Smoke test');
|
|
68
|
+
const current=(await (await call(path,owner)).json()).entry;
|
|
69
|
+
const writes=await Promise.all(['A','B'].map(title=>call(path,owner,'PATCH',{expectedVersion:current.version,data:{title}})));
|
|
70
|
+
assert.deepEqual(writes.map(x=>x.status).sort(),[200,409]);
|
|
71
|
+
r=await call(`/admin/api/entries/${entry.id}/publish?collection=articles`,owner,'POST',{});assert.equal(r.status,200,await r.clone().text());
|
|
72
|
+
const publicPage=await (await call(`/articles/${entry.id}`)).text();
|
|
73
|
+
assert.match(publicPage,/Published body from Mantle/);
|
|
74
|
+
assert.match(publicPage,/<h2>Published body from Mantle<\/h2>/);
|
|
75
|
+
assert.match(publicPage,new RegExp(mediaUrl));
|
|
76
|
+
assert.doesNotMatch(publicPage,/href="javascript:/);
|
|
77
|
+
assert.match(publicPage,/<meta property="og:image"/);
|
|
78
|
+
assert.match(publicPage,/<link rel="canonical"/);
|
|
79
|
+
assert.match(publicPage,/application\/ld\+json/);
|
|
80
|
+
assert.match(await (await call(`/articles/${entry.id}.md`)).text(),/Published body from Mantle/);
|
|
81
|
+
assert.match(await (await call('/articles')).text(),new RegExp(entry.id));
|
|
82
|
+
assert.match(await (await call('/llms.txt')).text(),new RegExp(entry.id));
|
|
83
|
+
assert.match(await (await call('/sitemap.xml')).text(),new RegExp(entry.id));
|
|
84
|
+
mcpResponse=await mcp('tools/call',{name:'query_view_published_articles',arguments:{}});assert.equal(mcpResponse.status,200,await mcpResponse.clone().text());
|
|
85
|
+
assert.match(JSON.stringify(await mcpResponse.json()),new RegExp(entry.id));
|
|
86
|
+
r=await fetch(base+'/admin/api/entries',{method:'POST',headers:{...owner,origin:'https://evil.example','content-type':'application/json'},body:'{}'});assert.equal(r.status,403);
|
|
87
|
+
r=await call(`/admin/api/staff/${encodeURIComponent('chatgpt:'+member['oai-authenticated-user-id'])}/role`,owner,'PATCH',{role:'editor'});assert.equal(r.status,200);
|
|
88
|
+
assert.equal((await call('/admin/api/me',member)).status,200);
|
|
89
|
+
r=await call(`/admin/api/staff/${encodeURIComponent('chatgpt:'+member['oai-authenticated-user-id'])}/role`,owner,'PATCH',{role:null});assert.equal(r.status,200);
|
|
90
|
+
assert.equal((await call('/admin/api/me',member)).status,403);
|
|
91
|
+
r=await call('/api/auth/sign-out',owner,'POST',{});assert.equal(r.status,200);
|
|
92
|
+
const cookie=r.headers.get('set-cookie').split(';')[0];
|
|
93
|
+
r=await call('/admin/sign-in',{...owner,cookie});assert.equal(r.headers.get('location'),'/signout-with-chatgpt?return_to=%2F');
|
|
94
|
+
assert.equal((await call(`/admin/api/entries/${entry.id}/unpublish?collection=articles`,owner,'POST',{})).status,200);
|
|
95
|
+
assert.equal((await call(`/articles/${entry.id}`)).status,404);
|
|
96
|
+
assert.equal((await call(path,owner,'DELETE',{})).status,200);
|
|
97
|
+
assert.equal((await call(`/admin/api/media/${upload.uploadGroupId}`,owner,'DELETE',{})).status,200);
|
|
98
|
+
assert.equal((await call(mediaUrl)).status,404);
|
|
99
|
+
console.log('PASS: D1 boot, R2/media upload and cleanup, identity and roles, CRUD/version conflict, CSRF, sign-out, public and Sites-session staff MCP, published article HTML/Markdown/SEO/llms/sitemap.');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// One-time, reviewable initial migration from Mantle's public migration planner.
|
|
2
|
+
import { buildSqliteMigrationArtifact, splitSqlStatements } from '@aotter/mantle/runtime';
|
|
3
|
+
import { plan } from '../.mantle/generated/mantle.ts';
|
|
4
|
+
import { writeFile } from 'node:fs/promises';
|
|
5
|
+
const artifact=await buildSqliteMigrationArtifact([],Object.values(plan.schemas).map(s=>s.manifest));
|
|
6
|
+
const quote=value=>`'${value.replaceAll("'","''")}'`;
|
|
7
|
+
const sql=[...artifact.migrations.flatMap(m=>splitSqlStatements(m.sql).map(s=>s+';')),...artifact.projections.map(p=>`INSERT INTO _mantle_schema_tables(name,projection) VALUES (${quote(p.name)},${quote(p.projection)});`),`INSERT INTO _mantle_storage_state(id,fingerprint) VALUES (1,${quote(artifact.targetFingerprint)});`].join('\n--> statement-breakpoint\n');
|
|
8
|
+
await writeFile('drizzle/0001_mantle.sql',sql);
|
|
9
|
+
await writeFile('src/storage-fingerprint.json',JSON.stringify(artifact.targetFingerprint)+'\n');
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AdminAuth, AdminStaffUser, StaffRole } from '@aotter/mantle/admin';
|
|
2
|
+
|
|
3
|
+
export interface Env { DB: D1Database; MEDIA_BUCKET: R2Bucket; ASSETS: Fetcher; PUBLIC_ORIGIN: string; OWNER_EMAIL: string }
|
|
4
|
+
type UserRow = { id: string; email: string; name: string; role: StaffRole | null; created_at: string; signed_in: number };
|
|
5
|
+
const project = (row: UserRow): AdminStaffUser => ({ id:row.id,email:row.email,name:row.name,role:row.role,githubLogin:null,emailVerified:!!row.signed_in,createdAt:new Date(row.created_at) });
|
|
6
|
+
|
|
7
|
+
// This adapter must only receive requests through Sites' identity-stripping dispatcher.
|
|
8
|
+
// Never expose the Worker independently with caller-controlled oai-* headers.
|
|
9
|
+
export function readIdentity(request: Request) {
|
|
10
|
+
const sub=request.headers.get('oai-authenticated-user-id');
|
|
11
|
+
const email=request.headers.get('oai-authenticated-user-email')?.trim().toLowerCase();
|
|
12
|
+
if(!sub || !email || sub.length>512 || email.length>320) return null;
|
|
13
|
+
let name=email;
|
|
14
|
+
if(request.headers.get('oai-authenticated-user-full-name-encoding')==='percent-encoded-utf-8') {
|
|
15
|
+
try { name=decodeURIComponent(request.headers.get('oai-authenticated-user-full-name')||'')||email; } catch {}
|
|
16
|
+
}
|
|
17
|
+
return {id:`chatgpt:${sub}`,email,name};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createChatGPTAuth(env: Env): AdminAuth {
|
|
21
|
+
const db=env.DB;
|
|
22
|
+
const roleFor=async(id:string)=>(await db.prepare('SELECT role FROM sites_users WHERE id = ?').bind(id).first<{role:StaffRole|null}>())?.role??null;
|
|
23
|
+
const auth: AdminAuth = {
|
|
24
|
+
basePath:'/api/auth', methods:[],
|
|
25
|
+
async getSession(request) {
|
|
26
|
+
const who=readIdentity(request); if(!who)return null;
|
|
27
|
+
// Only the configured owner can bootstrap privilege; a later role revocation persists.
|
|
28
|
+
await db.prepare('INSERT OR IGNORE INTO sites_users (id,email,name,role,signed_in) VALUES (?,?,?,?,1)')
|
|
29
|
+
.bind(who.id,who.email,who.name,who.email===env.OWNER_EMAIL?.trim().toLowerCase()?'owner':null).run();
|
|
30
|
+
await db.prepare('UPDATE sites_users SET id=?,name=?,signed_in=1 WHERE email=? AND signed_in=0').bind(who.id,who.name,who.email).run();
|
|
31
|
+
const row=await db.prepare('SELECT * FROM sites_users WHERE id=?').bind(who.id).first<UserRow>();
|
|
32
|
+
if(!row)return null;
|
|
33
|
+
return {session:{id:`sites:${who.id}`},user:{...who,role:row.role,roleCurrent:true}};
|
|
34
|
+
},
|
|
35
|
+
getUserRole:roleFor,
|
|
36
|
+
async handler(request) {
|
|
37
|
+
const path=new URL(request.url).pathname;
|
|
38
|
+
if(path==='/api/auth/get-session' && request.method==='GET') return Response.json(await auth.getSession(request));
|
|
39
|
+
if(path==='/api/auth/sign-out' && request.method==='POST') {
|
|
40
|
+
if(request.headers.get('origin')!==env.PUBLIC_ORIGIN)return new Response('Forbidden',{status:403});
|
|
41
|
+
// The shipped Admin UI navigates here after its POST; the next top-level request signs out at Sites.
|
|
42
|
+
return Response.json({success:true},{headers:{'set-cookie':'mantle-sites-signout=1; Path=/admin/sign-in; HttpOnly; SameSite=Strict; Max-Age=60'+(env.PUBLIC_ORIGIN.startsWith('https:')?'; Secure':'')}});
|
|
43
|
+
}
|
|
44
|
+
return Response.json({error:'unsupported_auth_route'},{status:404});
|
|
45
|
+
},
|
|
46
|
+
async listUsers(){const r=await db.prepare('SELECT * FROM sites_users WHERE role IS NOT NULL ORDER BY created_at,id').all<UserRow>();return r.results.map(project);},
|
|
47
|
+
async listMembers(args){
|
|
48
|
+
// ponytail: one bounded page for this integration lab; add cursor pagination before a large member rollout.
|
|
49
|
+
const r=await db.prepare('SELECT * FROM sites_users WHERE role IS NULL AND (email LIKE ? OR name LIKE ?) ORDER BY created_at,id LIMIT ?').bind(`%${args.search||''}%`,`%${args.search||''}%`,Math.min(args.limit,100)).all<UserRow>();
|
|
50
|
+
return {items:r.results.map(project),previousCursor:null,nextCursor:null};
|
|
51
|
+
},
|
|
52
|
+
async setUserRole(id,role){if(role!==null&&!['owner','editor','contributor'].includes(role))throw new Error('Invalid role');return !!(await db.prepare('UPDATE sites_users SET role=? WHERE id=?').bind(role,id).run()).meta.changes;},
|
|
53
|
+
async inviteUser(email,role){
|
|
54
|
+
const normalized=email.trim().toLowerCase();const id=`invite:${crypto.randomUUID()}`;
|
|
55
|
+
const r=await db.prepare('INSERT OR IGNORE INTO sites_users (id,email,name,role,signed_in) VALUES (?,?,?,?,0)').bind(id,normalized,normalized,role).run();
|
|
56
|
+
if(r.meta.changes)return {kind:'created',id};
|
|
57
|
+
const existing=await db.prepare('SELECT id FROM sites_users WHERE email=?').bind(normalized).first<{id:string}>();
|
|
58
|
+
if(!existing)throw new Error('Invitation could not be saved');return {kind:'exists',id:existing.id};
|
|
59
|
+
},
|
|
60
|
+
async revokeInvite(id){return !!(await db.prepare('DELETE FROM sites_users WHERE id=? AND signed_in=0').bind(id).run()).meta.changes;},
|
|
61
|
+
};
|
|
62
|
+
return auth;
|
|
63
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { bootMantleRuntime, SqliteMantleStorageAdapter } from '@aotter/mantle/runtime';
|
|
3
|
+
import { D1DatabaseDriver, AssetsAssetServer } from '@aotter/mantle/cloudflare';
|
|
4
|
+
import { mountMantleAdmin, type MantleAdminRuntime } from '@aotter/mantle/admin';
|
|
5
|
+
import { plan } from '../.mantle/generated/mantle';
|
|
6
|
+
import fingerprint from './storage-fingerprint.json';
|
|
7
|
+
import { createChatGPTAuth, type Env } from './chatgpt-auth';
|
|
8
|
+
import { mountR2Lab } from './r2-lab';
|
|
9
|
+
import { mountWeb } from './web';
|
|
10
|
+
import { SitesR2MediaStorage, mountMedia } from './media';
|
|
11
|
+
import { mountMcp } from './mcp';
|
|
12
|
+
|
|
13
|
+
function assemble(env:Env) {
|
|
14
|
+
const auth=createChatGPTAuth(env);
|
|
15
|
+
let runtime:Promise<MantleAdminRuntime>|undefined;
|
|
16
|
+
const get=()=>runtime??=(async()=>{
|
|
17
|
+
const r=await bootMantleRuntime({plan,storage:new SqliteMantleStorageAdapter(new D1DatabaseDriver(env.DB),{brand:'Mantle × Sites Reference',title:'Mantle × ChatGPT Sites Reference',origin:env.PUBLIC_ORIGIN,media:{purposes:[{name:'content',required:['image/jpeg,image/png,image/webp'],maxBytes:{'image/jpeg':5_000_000,'image/png':5_000_000,'image/webp':5_000_000}}]}},{managedStorageFingerprint:fingerprint}),ports:{mediaStorage:new SitesR2MediaStorage(env.MEDIA_BUCKET,env.PUBLIC_ORIGIN)}});
|
|
18
|
+
if(!r.siteConfig||!r.updateSiteSettings)throw new Error('Admin storage unavailable');
|
|
19
|
+
return Object.assign(r,{siteConfig:r.siteConfig,updateSiteSettings:r.updateSiteSettings});
|
|
20
|
+
})().catch(error=>{runtime=undefined;throw error;});
|
|
21
|
+
const app=new Hono<{Bindings:Env}>();
|
|
22
|
+
app.use('*',async(c,next)=>{await next();if(!c.res.headers.has('Cache-Control'))c.header('Cache-Control','private, no-store');c.header('X-Content-Type-Options','nosniff');});
|
|
23
|
+
mountWeb(app,get);
|
|
24
|
+
mountMcp(app,get,auth);
|
|
25
|
+
mountMedia(app,auth,env);
|
|
26
|
+
app.get('/health',async()=>{await get();return Response.json({ok:true,storage:'D1',auth:'ChatGPT Sites',mantle:'0.1.2'});});
|
|
27
|
+
app.get('/admin/sign-in',async c=>{
|
|
28
|
+
if(c.req.header('cookie')?.split(';').some(v=>v.trim()==='mantle-sites-signout=1')) {
|
|
29
|
+
c.header('Set-Cookie','mantle-sites-signout=; Path=/admin/sign-in; HttpOnly; SameSite=Strict; Max-Age=0'+(env.PUBLIC_ORIGIN.startsWith('https:')?'; Secure':''));
|
|
30
|
+
return c.redirect('/signout-with-chatgpt?return_to=%2F');
|
|
31
|
+
}
|
|
32
|
+
return c.redirect(await auth.getSession(c.req.raw)?'/admin':'/signin-with-chatgpt?return_to=%2Fadmin');
|
|
33
|
+
});
|
|
34
|
+
app.use('/admin/*',async(c,next)=>{
|
|
35
|
+
if(!c.req.path.startsWith('/admin/api/')&&!c.req.path.startsWith('/admin/lab/r2')&&!await auth.getSession(c.req.raw))return c.redirect('/signin-with-chatgpt?return_to=%2Fadmin');
|
|
36
|
+
await next();
|
|
37
|
+
});
|
|
38
|
+
mountR2Lab(app,auth,env);
|
|
39
|
+
mountMantleAdmin(app,{plan,auth,get,assets:new AssetsAssetServer(env.ASSETS),mcpEndpoints:{public:'/api/mcp',staff:'/api/mcp/staff'},requestContext:c=>({env:c.env,waitUntil:p=>c.executionCtx.waitUntil(p)})});
|
|
40
|
+
app.get('/site.css',c=>env.ASSETS.fetch(c.req.raw));
|
|
41
|
+
app.get('/_mantle/*',c=>env.ASSETS.fetch(c.req.raw));
|
|
42
|
+
app.onError(error=>{console.error('Mantle request failed',error);return Response.json({error:'internal_error'},{status:500,headers:{'Cache-Control':'private, no-store'}});});
|
|
43
|
+
return app;
|
|
44
|
+
}
|
|
45
|
+
let app:ReturnType<typeof assemble>|undefined;
|
|
46
|
+
export default {fetch(request:Request,env:Env,ctx:ExecutionContext){return (app??=assemble(env)).fetch(request,env,ctx);}};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import type { AdminAuth, MantleAdminRuntime, StaffRole } from '@aotter/mantle/admin';
|
|
3
|
+
import { McpJsonRpcDispatcher, projectCallableCapabilities } from '@aotter/mantle/runtime';
|
|
4
|
+
import { plan } from '../.mantle/generated/mantle';
|
|
5
|
+
import type { Env } from './chatgpt-auth';
|
|
6
|
+
|
|
7
|
+
const staffRoles=new Set<string>(['owner','editor','contributor']);
|
|
8
|
+
|
|
9
|
+
/** Sites-session MCP surfaces. Remote clients still need a separate OAuth bearer flow. */
|
|
10
|
+
export function mountMcp(app:Hono<{Bindings:Env}>, get:()=>Promise<MantleAdminRuntime>, auth:AdminAuth) {
|
|
11
|
+
const cache=new WeakMap<object,Map<'public'|'staff',McpJsonRpcDispatcher>>();
|
|
12
|
+
const dispatcher=async(surface:'public'|'staff',origin:string)=>{
|
|
13
|
+
const runtime=await get();
|
|
14
|
+
let dispatchers=cache.get(runtime);
|
|
15
|
+
if(!dispatchers)cache.set(runtime,dispatchers=new Map());
|
|
16
|
+
let value=dispatchers.get(surface);
|
|
17
|
+
if(!value) {
|
|
18
|
+
value=new McpJsonRpcDispatcher({
|
|
19
|
+
getEntry:runtime.getEntry,
|
|
20
|
+
createDraft:runtime.createDraft,
|
|
21
|
+
updateDraft:runtime.updateDraft,
|
|
22
|
+
requestPublish:runtime.requestPublish,
|
|
23
|
+
unpublish:runtime.unpublish,
|
|
24
|
+
archive:runtime.archive,
|
|
25
|
+
deleteEntry:runtime.deleteEntry,
|
|
26
|
+
executeView:{execute:request=>runtime.executeView({...request,view:request.view.metadata.name})},
|
|
27
|
+
invokeTrigger:{execute:request=>runtime.invokeTrigger(request)},
|
|
28
|
+
},[...runtime.schemas.values()],{
|
|
29
|
+
surface,
|
|
30
|
+
capabilities:projectCallableCapabilities(plan,{surface}),
|
|
31
|
+
serverInfo:{name:`aotter.mantle.${surface}`,title:`Mantle ${surface}`,websiteUrl:origin},
|
|
32
|
+
});
|
|
33
|
+
dispatchers.set(surface,value);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
37
|
+
app.all('/api/mcp',async c=>(await dispatcher('public',c.env.PUBLIC_ORIGIN)).dispatch(c.req.raw,{user:null,staff:null,env:c.env,waitUntil:p=>c.executionCtx.waitUntil(p)}));
|
|
38
|
+
app.all('/api/mcp/staff',async c=>{
|
|
39
|
+
const session=await auth.getSession(c.req.raw);
|
|
40
|
+
if(!session)return Response.json({error:'unauthorized'},{status:401});
|
|
41
|
+
const role=session.user.roleCurrent?session.user.role:await auth.getUserRole(session.user.id);
|
|
42
|
+
if(!role||!staffRoles.has(role))return Response.json({error:'forbidden'},{status:403});
|
|
43
|
+
return (await dispatcher('staff',c.env.PUBLIC_ORIGIN)).dispatch(c.req.raw,{
|
|
44
|
+
user:{id:session.user.id},staff:{id:session.user.id,role:role as StaffRole},
|
|
45
|
+
auth:{credential:'session',credentialId:session.session.id,clientId:null,scopes:[]},
|
|
46
|
+
env:c.env,waitUntil:p=>c.executionCtx.waitUntil(p),
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { DiagnosticError, makeDiagnostic } from '@aotter/mantle/spec';
|
|
3
|
+
import { extensionForMime, type MediaStorage, type MediaAsset, type MediaVariant } from '@aotter/mantle/runtime';
|
|
4
|
+
import type { AdminAuth } from '@aotter/mantle/admin';
|
|
5
|
+
import type { Env } from './chatgpt-auth';
|
|
6
|
+
|
|
7
|
+
const mimes = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
|
8
|
+
const groupPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
9
|
+
const filePattern = /^(primary|alternate|fallback)\.(jpg|png|webp)$/;
|
|
10
|
+
|
|
11
|
+
type Pending = { expiresAt: number; variants: { mimeType: string; storageKey: string; expectedSize: number; maxBytes: number }[] };
|
|
12
|
+
|
|
13
|
+
function failure(code: 'MEDIA_OBJECT_NOT_FOUND' | 'MEDIA_MIME_REJECTED' | 'MEDIA_VARIANT_SIZE_EXCEEDED') {
|
|
14
|
+
return new DiagnosticError(makeDiagnostic({code, phase:'runtime', severity:'error', path:'sites/R2MediaStorage'}));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Public media over the Sites R2 binding; no S3 credentials or public bucket URL. */
|
|
18
|
+
export class SitesR2MediaStorage implements MediaStorage {
|
|
19
|
+
constructor(private readonly bucket:R2Bucket, private readonly origin:string) {}
|
|
20
|
+
|
|
21
|
+
async createUpload(args:Parameters<MediaStorage['createUpload']>[0]) {
|
|
22
|
+
const capabilities=args.variants.map(variant=>{
|
|
23
|
+
const storageKey=`${args.purpose}/${args.uploadGroupId}/${variant.role}.${extensionForMime(variant.mimeType)}`;
|
|
24
|
+
return {
|
|
25
|
+
mimeType:variant.mimeType, role:variant.role, method:'PUT' as const,
|
|
26
|
+
uploadUrl:`${this.origin}/admin/media-upload/${args.uploadGroupId}/${storageKey.split('/').at(-1)}`,
|
|
27
|
+
storageKey, publicUrl:`${this.origin}/media/${storageKey}`,
|
|
28
|
+
requiredHeaders:{'Content-Type':variant.mimeType},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
return {uploadGroupId:args.uploadGroupId, capabilities, expiresAt:args.expiresAt};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async commitUpload(args:Parameters<MediaStorage['commitUpload']>[0]):Promise<MediaAsset> {
|
|
35
|
+
const checked=[] as {spec:(typeof args.variants)[number]; size:number}[];
|
|
36
|
+
for(const spec of args.variants) {
|
|
37
|
+
const head=await this.bucket.head(spec.storageKey);
|
|
38
|
+
if(!head)throw failure('MEDIA_OBJECT_NOT_FOUND');
|
|
39
|
+
if(head.httpMetadata?.contentType!==spec.mimeType)throw failure('MEDIA_MIME_REJECTED');
|
|
40
|
+
if(head.size>spec.maxBytes)throw failure('MEDIA_VARIANT_SIZE_EXCEEDED');
|
|
41
|
+
checked.push({spec,size:head.size});
|
|
42
|
+
}
|
|
43
|
+
const variants:MediaVariant[]=[];
|
|
44
|
+
for(const {spec,size} of checked) {
|
|
45
|
+
const object=await this.bucket.get(spec.storageKey);
|
|
46
|
+
if(!object)throw failure('MEDIA_OBJECT_NOT_FOUND');
|
|
47
|
+
await this.bucket.put(spec.storageKey,object.body,{
|
|
48
|
+
httpMetadata:{contentType:spec.mimeType},
|
|
49
|
+
customMetadata:{...object.customMetadata,committedAt:String(args.now),uploadGroupId:args.uploadGroupId,role:spec.role,filename:args.filename},
|
|
50
|
+
});
|
|
51
|
+
variants.push({mimeType:spec.mimeType,role:spec.role,storageKey:spec.storageKey,byteSize:size,publicUrl:await this.getPublicUrl({storageKey:spec.storageKey})});
|
|
52
|
+
}
|
|
53
|
+
return {id:args.uploadGroupId,variants,alt:args.alt,caption:args.caption,createdAt:args.now,metadata:{filename:args.filename}};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async getPublicUrl({storageKey}:Parameters<MediaStorage['getPublicUrl']>[0]) {return `${this.origin}/media/${storageKey}`;}
|
|
57
|
+
async deleteObject({storageKey}:Parameters<MediaStorage['deleteObject']>[0]) {await this.bucket.delete(storageKey);}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function mountMedia(app:Hono<{Bindings:Env}>,auth:AdminAuth,env:Env) {
|
|
61
|
+
app.put('/admin/media-upload/:group/:file',async c=>{
|
|
62
|
+
const session=await auth.getSession(c.req.raw);
|
|
63
|
+
if(!session)return c.json({error:'unauthenticated'},401);
|
|
64
|
+
if(session.user.role!=='owner'&&session.user.role!=='editor')return c.json({error:'editor_required'},403);
|
|
65
|
+
if(c.req.header('origin')!==env.PUBLIC_ORIGIN)return c.json({error:'cross_origin'},403);
|
|
66
|
+
const group=c.req.param('group'),file=c.req.param('file');
|
|
67
|
+
if(!groupPattern.test(group)||!filePattern.test(file))return c.json({error:'invalid_upload'},400);
|
|
68
|
+
const row=await env.DB.prepare('SELECT record FROM pending_media_uploads WHERE id=?').bind(group).first<{record:string}>();
|
|
69
|
+
if(!row)return c.json({error:'upload_expired'},410);
|
|
70
|
+
const pending=JSON.parse(row.record) as Pending;
|
|
71
|
+
if(pending.expiresAt<=Date.now())return c.json({error:'upload_expired'},410);
|
|
72
|
+
const key=`content/${group}/${file}`;
|
|
73
|
+
const variant=pending.variants.find(v=>v.storageKey===key);
|
|
74
|
+
if(!variant||!mimes.has(variant.mimeType))return c.json({error:'invalid_upload'},400);
|
|
75
|
+
if(c.req.header('content-type')!==variant.mimeType)return c.json({error:'mime_rejected'},415);
|
|
76
|
+
const length=Number(c.req.header('content-length'));
|
|
77
|
+
if(Number.isFinite(length)&&length>variant.expectedSize)return c.json({error:'size_exceeded'},413);
|
|
78
|
+
if(!c.req.raw.body)return c.json({error:'empty_upload'},400);
|
|
79
|
+
// R2 put requires a fixed-length body. Bound memory to this validated 5 MB policy.
|
|
80
|
+
const reader=c.req.raw.body.getReader(),chunks:Uint8Array[]=[];
|
|
81
|
+
let bytes=0;
|
|
82
|
+
for(;;) {
|
|
83
|
+
const {done,value}=await reader.read();
|
|
84
|
+
if(done)break;
|
|
85
|
+
bytes+=value.byteLength;
|
|
86
|
+
if(bytes>variant.expectedSize){await reader.cancel();return c.json({error:'size_exceeded'},413);}
|
|
87
|
+
chunks.push(value);
|
|
88
|
+
}
|
|
89
|
+
if(bytes!==variant.expectedSize)return c.json({error:'size_mismatch'},400);
|
|
90
|
+
const body=new Uint8Array(bytes);
|
|
91
|
+
let offset=0;
|
|
92
|
+
for(const chunk of chunks){body.set(chunk,offset);offset+=chunk.byteLength;}
|
|
93
|
+
await env.MEDIA_BUCKET.put(key,body,{httpMetadata:{contentType:variant.mimeType}});
|
|
94
|
+
return c.body(null,204);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
app.get('/media/:purpose/:group/:file',async c=>{
|
|
98
|
+
const {purpose,group,file}=c.req.param();
|
|
99
|
+
if(purpose!=='content'||!groupPattern.test(group)||!filePattern.test(file))return c.notFound();
|
|
100
|
+
const key=`${purpose}/${group}/${file}`;
|
|
101
|
+
const row=await env.DB.prepare('SELECT variants FROM media_assets WHERE id=?').bind(group).first<{variants:string}>();
|
|
102
|
+
if(!row)return c.notFound();
|
|
103
|
+
const variants=JSON.parse(row.variants) as MediaVariant[];
|
|
104
|
+
if(!variants.some(v=>v.storageKey===key))return c.notFound();
|
|
105
|
+
const object=await env.MEDIA_BUCKET.get(key);
|
|
106
|
+
if(!object||!mimes.has(object.httpMetadata?.contentType??''))return c.notFound();
|
|
107
|
+
return new Response(object.body,{headers:{'Content-Type':object.httpMetadata!.contentType!,'Content-Length':String(object.size),'Cache-Control':'public, max-age=3600','X-Content-Type-Options':'nosniff'}});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import type { AdminAuth } from '@aotter/mantle/admin';
|
|
3
|
+
import type { Env } from './chatgpt-auth';
|
|
4
|
+
|
|
5
|
+
// Deliberately a binding probe, not an implementation of Mantle's public MediaStorage port.
|
|
6
|
+
export function mountR2Lab(app:Hono<{Bindings:Env}>,auth:AdminAuth,env:Env) {
|
|
7
|
+
app.use('/admin/lab*',async(c,next)=>{
|
|
8
|
+
const session=await auth.getSession(c.req.raw);
|
|
9
|
+
if(!session)return c.json({error:'unauthenticated'},401);
|
|
10
|
+
if(session.user.role!=='owner')return c.json({error:'owner_required'},403);
|
|
11
|
+
if(c.req.method!=='GET'&&c.req.header('origin')!==env.PUBLIC_ORIGIN)return c.json({error:'cross_origin'},403);
|
|
12
|
+
await next();
|
|
13
|
+
});
|
|
14
|
+
app.get('/admin/lab',c=>c.html(`<!doctype html><html lang="zh-Hant"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>R2 驗證 · Mantle Sites</title><style>body{font:18px system-ui;max-width:760px;margin:60px auto;padding:24px;background:#101820;color:#eef5ff}a{color:#69dbff}button{font:inherit;padding:12px 20px;cursor:pointer}pre{white-space:pre-wrap}</style><h1>R2 binding 驗證</h1><p>以目前的 owner 身分寫入一個測試物件,在另一個請求讀取並核對內容,最後清理該物件。這不是媒體庫上傳測試。</p><button id="run">執行 R2 測試</button><pre id="result" role="status"></pre><a href="/admin">回管理後台</a><script>
|
|
15
|
+
const button=document.querySelector('#run'),result=document.querySelector('#result');
|
|
16
|
+
button.onclick=async()=>{button.disabled=true;result.textContent='測試中…';let id;try{
|
|
17
|
+
const send=async(method,path)=>{const r=await fetch(path,{method});if(!r.ok)throw Error('HTTP '+r.status);return r.json()};
|
|
18
|
+
const created=await send('POST','/admin/lab/r2');id=created.id;
|
|
19
|
+
const read=await send('GET','/admin/lab/r2/'+id);if(!read.matches)throw Error('內容不一致');
|
|
20
|
+
const deleted=await send('DELETE','/admin/lab/r2/'+id);if(!deleted.deleted)throw Error('清理未完成');id=null;
|
|
21
|
+
result.textContent='PASS:R2 PUT → HEAD / GET(另一個請求)→ 內容核對 → DELETE / HEAD 確認已清理。';
|
|
22
|
+
}catch(e){result.textContent='FAIL:'+e.message+(id?';待清理測試 ID:'+id:'')}finally{button.disabled=false}};
|
|
23
|
+
</script></html>`));
|
|
24
|
+
app.post('/admin/lab/r2',async c=>{
|
|
25
|
+
const id=crypto.randomUUID();
|
|
26
|
+
await env.MEDIA_BUCKET.put(`_sites_binding_probe/${id}`,`mantle-sites-r2:${id}`,{httpMetadata:{contentType:'text/plain'},customMetadata:{purpose:'disposable-binding-probe'}});
|
|
27
|
+
return c.json({id});
|
|
28
|
+
});
|
|
29
|
+
app.on(['GET','DELETE'],'/admin/lab/r2/:id',async c=>{
|
|
30
|
+
const id=c.req.param('id');
|
|
31
|
+
if(!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id))return c.json({error:'invalid_id'},400);
|
|
32
|
+
const key=`_sites_binding_probe/${id}`,head=await env.MEDIA_BUCKET.head(key);
|
|
33
|
+
if(!head||head.customMetadata?.purpose!=='disposable-binding-probe')return c.json({error:'not_found'},404);
|
|
34
|
+
if(c.req.method==='DELETE'){await env.MEDIA_BUCKET.delete(key);return c.json({deleted:await env.MEDIA_BUCKET.head(key)===null});}
|
|
35
|
+
const object=await env.MEDIA_BUCKET.get(key);
|
|
36
|
+
return c.json({matches:!!object&&await object.text()===`mantle-sites-r2:${id}`,size:head.size});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"9ef85f9933e22328140e924045820af6be2a6985ed8ecc19b63a4f2a37826618"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import type { MantleAdminRuntime } from '@aotter/mantle/admin';
|
|
3
|
+
import { createMantleWeb, createPublicPathResolver, composeEntrySeoMeta, composePageSeoMeta, renderSeoTagsHtml, serializeEntryAsMarkdown, TemplateRegistry } from '@aotter/mantle-web';
|
|
4
|
+
import { micromark } from 'micromark';
|
|
5
|
+
import type { Env } from './chatgpt-auth';
|
|
6
|
+
|
|
7
|
+
const paths=createPublicPathResolver({collectionRoutes:{articles:{segment:'articles'}}});
|
|
8
|
+
const escape=(value:unknown)=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
|
|
9
|
+
const articlePath=(id:string)=>`/articles/${encodeURIComponent(id)}`;
|
|
10
|
+
const shell=(title:string,head:string,body:string)=>`<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)}</title>${head}<link rel="stylesheet" href="/site.css"><style>.article-cover{margin:36px 0}.article-cover img{display:block;width:100%;height:auto;border-radius:12px}.prose img{max-width:100%;height:auto}</style></head><body><header class="topbar"><a class="brand" href="/"><span class="mark">M</span><span>Mantle <em>×</em> Sites</span></a><nav aria-label="主要導覽"><a href="/articles">文章</a><a href="/admin">管理後台 ↗</a></nav></header>${body}<footer><span>Mantle on ChatGPT Sites</span><a href="/llms.txt">給 AI 的內容索引</a></footer></body></html>`;
|
|
11
|
+
const templates=new TemplateRegistry();
|
|
12
|
+
templates.registerListTemplate('articles',({entries,nextPageUrl})=>`<section class="article-list">${entries.length?entries.map(entry=>`<article class="article-row"><div class="article-number">${new Date(entry.updatedAt).toLocaleDateString('zh-TW',{timeZone:'UTC'})}</div><div><h2><a href="${articlePath(entry.id)}">${escape(entry.data.title)}</a></h2><p>${escape(entry.data.summary||'閱讀完整文章')}</p></div><span aria-hidden="true">↗</span></article>`).join(''):'<div class="empty"><h2>文章準備中</h2><p>在管理後台發布文章後,就會自動出現在這裡。</p></div>'}</section>${nextPageUrl?`<nav class="pagination" aria-label="分頁"><a rel="next" href="${escape(nextPageUrl)}">更多文章 →</a></nav>`:''}`);
|
|
13
|
+
|
|
14
|
+
export function mountWeb(app:Hono<{Bindings:Env}>,get:()=>Promise<MantleAdminRuntime>) {
|
|
15
|
+
const load=async()=>{const runtime=await get();return {runtime,site:await runtime.siteConfig.load(),web:createMantleWeb(runtime,{templates,paths})};};
|
|
16
|
+
const list=async(cursor?:string)=>{const {site,web}=await load();const seo=composePageSeoMeta({site,locale:'zh-TW',publicPath:'/articles',title:`文章|${site.title}`,description:site.description||'由 Mantle 管理與發布的文章。',markdown:false,pathForLocale:()=>'/articles'});const page=await web.renderListLive.execute({collection:'articles',locale:'zh-TW',contentLocale:null,site,seo,cursor,limit:20,pathForPage:next=>`/articles?cursor=${encodeURIComponent(next)}`});return shell(`文章|${site.title}`,renderSeoTagsHtml(seo),`<main><section class="hero small"><p class="eyebrow">THE JOURNAL</p><h1>文章與觀察<span class="dot">.</span></h1><p class="hero-copy">內容由 Mantle 後台管理,從 D1 即時讀取。</p></section><div class="content-wrap">${page?.html.replace(/^<!doctype html>/i,'')??''}</div></main>`);};
|
|
17
|
+
app.get('/',async c=>{const {site}=await load();const seo=composePageSeoMeta({site,locale:'zh-TW',publicPath:'/',title:site.title,description:site.description||'Mantle 在 ChatGPT Sites 上的內容實驗。',markdown:false,pathForLocale:()=>'/'});return c.html(shell(site.title,renderSeoTagsHtml(seo),`<main><section class="hero"><p class="eyebrow">BUILT WITH MANTLE · RUNNING ON CHATGPT SITES</p><h1>內容、資料、<br>與下一種網站<span class="dot">.</span></h1><p class="hero-copy">這個站點連接 Mantle 管理後台與 Sites 的 D1 資料庫。文章發布後,會自動生成可閱讀、可索引的頁面。</p><div class="actions"><a class="button" href="/articles">探索文章 <span>↗</span></a><a class="text-link" href="/admin">進入管理後台 →</a></div></section><section class="feature-grid" aria-label="網站能力"><div><span class="feature-index">01 / CONTENT</span><h2>一處編輯</h2><p>文章在 Mantle 後台建立與發布,前台直接讀取同一份內容。</p></div><div><span class="feature-index">02 / DISCOVERY</span><h2>搜尋與 AI 可讀</h2><p>標準 metadata、結構化資料、Markdown 鏡像與內容索引。</p></div><div><span class="feature-index">03 / PLATFORM</span><h2>託管於 Sites</h2><p>D1 保存文章,R2 圖片可在後台上傳並綁定文章封面。</p></div></section></main>`));});
|
|
18
|
+
app.get('/articles',async c=>c.html(await list(c.req.query('cursor'))));
|
|
19
|
+
app.get('/articles/:id',async c=>{const {runtime,site}=await load();const rawId=c.req.param('id');const markdown=rawId.endsWith('.md');const entry=await runtime.entries.readById({collection:'articles',id:markdown?rawId.slice(0,-3):rawId});if(!entry||entry.status!=='published')return c.notFound();if(markdown){const text=serializeEntryAsMarkdown(entry);return text?c.text(text,200,{'Content-Type':'text/markdown; charset=utf-8'}):c.notFound();}
|
|
20
|
+
const coverId=typeof entry.data.coverAssetId==='string'?entry.data.coverAssetId:'';
|
|
21
|
+
const cover=coverId?await runtime.media?.resolve(coverId):null;
|
|
22
|
+
const primary=cover?.variants.find(v=>v.role==='primary');
|
|
23
|
+
const coverUrl=primary?.publicUrl;
|
|
24
|
+
const seoEntry=coverUrl?{...entry,data:{...entry.data,coverUrl}}:entry;
|
|
25
|
+
const path=articlePath(entry.id),seo=composeEntrySeoMeta({entry:seoEntry,site,publicPath:path,locale:'zh-TW',type:'article'});
|
|
26
|
+
const title=String(entry.data.title??site.title);
|
|
27
|
+
const image=coverUrl?`<figure class="article-cover"><img src="${escape(coverUrl)}" alt="${escape(cover?.alt||title)}" loading="eager"></figure>`:'';
|
|
28
|
+
const body=micromark(String(entry.data.body??''),{allowDangerousHtml:false,allowDangerousProtocol:false});
|
|
29
|
+
return c.html(shell(`${title}|${site.title}`,renderSeoTagsHtml(seo),`<main class="article-page"><a class="back" href="/articles">← 返回文章</a><article><p class="eyebrow">ARTICLE · ${new Date(entry.updatedAt).toLocaleDateString('zh-TW',{timeZone:'UTC'})}</p><h1>${escape(title)}</h1>${entry.data.summary?`<p class="lede">${escape(entry.data.summary)}</p>`:''}${image}<div class="prose">${body}</div>${seo.alternateMarkdown?`<a class="markdown-link" href="${articlePath(entry.id)}.md">閱讀 Markdown 版本 ↗</a>`:''}</article></main>`));});
|
|
30
|
+
// ponytail: one discovery page is enough for this lab; expose cursor pages when published articles exceed the SDK page limit.
|
|
31
|
+
app.get('/llms.txt',async c=>{const {site,web}=await load();const page=await web.composeLlmsTxt.execute({collections:['articles'],locale:null,site,pathFor:e=>articlePath(e.id)});const body=page?.body??`# ${site.title}\n\n文章索引:${site.origin}/articles\n`;return c.text(body,200,{'Content-Type':'text/plain; charset=utf-8'});});
|
|
32
|
+
// ponytail: first 1000 published URLs; add sitemap index parts only when the catalogue reaches that ceiling.
|
|
33
|
+
app.get('/sitemap.xml',async c=>{const {site,web}=await load();const page=await web.composeSitemap.execute({site,collections:['articles'],pathFor:e=>articlePath(e.id),additionalPaths:['/','/articles'],maxUrls:1000});return c.text(page.body,200,{'Content-Type':'application/xml; charset=utf-8'});});
|
|
34
|
+
app.get('/robots.txt',async c=>c.text(`User-agent: *\nAllow: /\nSitemap: ${c.env.PUBLIC_ORIGIN}/sitemap.xml\n`));
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","strict":true,"skipLibCheck":true,"noEmit":true,"types":["@cloudflare/workers-types"],"lib":["ES2022","DOM"]},"include":["src/**/*.ts",".mantle/generated/**/*.ts"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mantle-sites-reference",
|
|
3
|
+
"main": "dist/server/index.js",
|
|
4
|
+
"compatibility_date": "2026-09-08",
|
|
5
|
+
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
6
|
+
"assets": {"directory":"dist/client","binding":"ASSETS","run_worker_first":true},
|
|
7
|
+
"d1_databases": [{"binding":"DB","database_name":"mantle-sites-reference-local","database_id":"local","migrations_dir":"drizzle"}],
|
|
8
|
+
"r2_buckets": [{"binding":"MEDIA_BUCKET","bucket_name":"mantle-sites-reference-local-media"}],
|
|
9
|
+
"vars": {"PUBLIC_ORIGIN":"http://localhost:4174","OWNER_EMAIL":"owner@example.test"}
|
|
10
|
+
}
|