@polardbx/pxbaas 0.0.0-stage → 0.1.0-beta.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.
Files changed (267) hide show
  1. package/README.md +107 -2
  2. package/bin/pxbaas.js +13 -0
  3. package/dist/app/preflight.d.ts +38 -0
  4. package/dist/app/preflight.d.ts.map +1 -0
  5. package/dist/app/preflight.js +79 -0
  6. package/dist/app/preflight.js.map +1 -0
  7. package/dist/app/spec-lint.d.ts +45 -0
  8. package/dist/app/spec-lint.d.ts.map +1 -0
  9. package/dist/app/spec-lint.js +407 -0
  10. package/dist/app/spec-lint.js.map +1 -0
  11. package/dist/app/templates.d.ts +143 -0
  12. package/dist/app/templates.d.ts.map +1 -0
  13. package/dist/app/templates.js +151 -0
  14. package/dist/app/templates.js.map +1 -0
  15. package/dist/cli/root.d.ts +3 -0
  16. package/dist/cli/root.d.ts.map +1 -0
  17. package/dist/cli/root.js +69 -0
  18. package/dist/cli/root.js.map +1 -0
  19. package/dist/commands/app/index.d.ts +8 -0
  20. package/dist/commands/app/index.d.ts.map +1 -0
  21. package/dist/commands/app/index.js +1476 -0
  22. package/dist/commands/app/index.js.map +1 -0
  23. package/dist/commands/auth/index.d.ts +3 -0
  24. package/dist/commands/auth/index.d.ts.map +1 -0
  25. package/dist/commands/auth/index.js +198 -0
  26. package/dist/commands/auth/index.js.map +1 -0
  27. package/dist/commands/completion/index.d.ts +3 -0
  28. package/dist/commands/completion/index.d.ts.map +1 -0
  29. package/dist/commands/completion/index.js +10 -0
  30. package/dist/commands/completion/index.js.map +1 -0
  31. package/dist/commands/data/index.d.ts +3 -0
  32. package/dist/commands/data/index.d.ts.map +1 -0
  33. package/dist/commands/data/index.js +245 -0
  34. package/dist/commands/data/index.js.map +1 -0
  35. package/dist/commands/db/index.d.ts +15 -0
  36. package/dist/commands/db/index.d.ts.map +1 -0
  37. package/dist/commands/db/index.js +389 -0
  38. package/dist/commands/db/index.js.map +1 -0
  39. package/dist/commands/doctor/index.d.ts +3 -0
  40. package/dist/commands/doctor/index.d.ts.map +1 -0
  41. package/dist/commands/doctor/index.js +305 -0
  42. package/dist/commands/doctor/index.js.map +1 -0
  43. package/dist/commands/functions/delete.d.ts +3 -0
  44. package/dist/commands/functions/delete.d.ts.map +1 -0
  45. package/dist/commands/functions/delete.js +55 -0
  46. package/dist/commands/functions/delete.js.map +1 -0
  47. package/dist/commands/functions/deploy.d.ts +3 -0
  48. package/dist/commands/functions/deploy.d.ts.map +1 -0
  49. package/dist/commands/functions/deploy.js +118 -0
  50. package/dist/commands/functions/deploy.js.map +1 -0
  51. package/dist/commands/functions/index.d.ts +3 -0
  52. package/dist/commands/functions/index.d.ts.map +1 -0
  53. package/dist/commands/functions/index.js +15 -0
  54. package/dist/commands/functions/index.js.map +1 -0
  55. package/dist/commands/functions/list.d.ts +6 -0
  56. package/dist/commands/functions/list.d.ts.map +1 -0
  57. package/dist/commands/functions/list.js +63 -0
  58. package/dist/commands/functions/list.js.map +1 -0
  59. package/dist/commands/functions/new.d.ts +3 -0
  60. package/dist/commands/functions/new.d.ts.map +1 -0
  61. package/dist/commands/functions/new.js +57 -0
  62. package/dist/commands/functions/new.js.map +1 -0
  63. package/dist/commands/gen/index.d.ts +3 -0
  64. package/dist/commands/gen/index.d.ts.map +1 -0
  65. package/dist/commands/gen/index.js +320 -0
  66. package/dist/commands/gen/index.js.map +1 -0
  67. package/dist/commands/instance/index.d.ts +8 -0
  68. package/dist/commands/instance/index.d.ts.map +1 -0
  69. package/dist/commands/instance/index.js +361 -0
  70. package/dist/commands/instance/index.js.map +1 -0
  71. package/dist/commands/login/index.d.ts +3 -0
  72. package/dist/commands/login/index.d.ts.map +1 -0
  73. package/dist/commands/login/index.js +160 -0
  74. package/dist/commands/login/index.js.map +1 -0
  75. package/dist/commands/login/login.d.ts +3 -0
  76. package/dist/commands/login/login.d.ts.map +1 -0
  77. package/dist/commands/login/login.js +185 -0
  78. package/dist/commands/login/login.js.map +1 -0
  79. package/dist/commands/logout/index.d.ts +3 -0
  80. package/dist/commands/logout/index.d.ts.map +1 -0
  81. package/dist/commands/logout/index.js +41 -0
  82. package/dist/commands/logout/index.js.map +1 -0
  83. package/dist/commands/profile/import-env.d.ts +13 -0
  84. package/dist/commands/profile/import-env.d.ts.map +1 -0
  85. package/dist/commands/profile/import-env.js +81 -0
  86. package/dist/commands/profile/import-env.js.map +1 -0
  87. package/dist/commands/profile/index.d.ts +3 -0
  88. package/dist/commands/profile/index.d.ts.map +1 -0
  89. package/dist/commands/profile/index.js +112 -0
  90. package/dist/commands/profile/index.js.map +1 -0
  91. package/dist/commands/rest/index.d.ts +3 -0
  92. package/dist/commands/rest/index.d.ts.map +1 -0
  93. package/dist/commands/rest/index.js +102 -0
  94. package/dist/commands/rest/index.js.map +1 -0
  95. package/dist/commands/skill/index.d.ts +11 -0
  96. package/dist/commands/skill/index.d.ts.map +1 -0
  97. package/dist/commands/skill/index.js +153 -0
  98. package/dist/commands/skill/index.js.map +1 -0
  99. package/dist/commands/status/index.d.ts +3 -0
  100. package/dist/commands/status/index.d.ts.map +1 -0
  101. package/dist/commands/status/index.js +59 -0
  102. package/dist/commands/status/index.js.map +1 -0
  103. package/dist/commands/storage/index.d.ts +3 -0
  104. package/dist/commands/storage/index.d.ts.map +1 -0
  105. package/dist/commands/storage/index.js +198 -0
  106. package/dist/commands/storage/index.js.map +1 -0
  107. package/dist/commands/whoami/index.d.ts +3 -0
  108. package/dist/commands/whoami/index.d.ts.map +1 -0
  109. package/dist/commands/whoami/index.js +82 -0
  110. package/dist/commands/whoami/index.js.map +1 -0
  111. package/dist/credentials/commit.d.ts +38 -0
  112. package/dist/credentials/commit.d.ts.map +1 -0
  113. package/dist/credentials/commit.js +57 -0
  114. package/dist/credentials/commit.js.map +1 -0
  115. package/dist/credentials/credential-store.d.ts +57 -0
  116. package/dist/credentials/credential-store.d.ts.map +1 -0
  117. package/dist/credentials/credential-store.js +180 -0
  118. package/dist/credentials/credential-store.js.map +1 -0
  119. package/dist/credentials/env-file.d.ts +44 -0
  120. package/dist/credentials/env-file.d.ts.map +1 -0
  121. package/dist/credentials/env-file.js +118 -0
  122. package/dist/credentials/env-file.js.map +1 -0
  123. package/dist/credentials/formatter.d.ts +39 -0
  124. package/dist/credentials/formatter.d.ts.map +1 -0
  125. package/dist/credentials/formatter.js +79 -0
  126. package/dist/credentials/formatter.js.map +1 -0
  127. package/dist/credentials/index.d.ts +13 -0
  128. package/dist/credentials/index.d.ts.map +1 -0
  129. package/dist/credentials/index.js +13 -0
  130. package/dist/credentials/index.js.map +1 -0
  131. package/dist/credentials/jwt-introspect.d.ts +32 -0
  132. package/dist/credentials/jwt-introspect.d.ts.map +1 -0
  133. package/dist/credentials/jwt-introspect.js +64 -0
  134. package/dist/credentials/jwt-introspect.js.map +1 -0
  135. package/dist/credentials/masker.d.ts +20 -0
  136. package/dist/credentials/masker.d.ts.map +1 -0
  137. package/dist/credentials/masker.js +37 -0
  138. package/dist/credentials/masker.js.map +1 -0
  139. package/dist/credentials/profile.d.ts +83 -0
  140. package/dist/credentials/profile.d.ts.map +1 -0
  141. package/dist/credentials/profile.js +184 -0
  142. package/dist/credentials/profile.js.map +1 -0
  143. package/dist/credentials/tenant-guard.d.ts +16 -0
  144. package/dist/credentials/tenant-guard.d.ts.map +1 -0
  145. package/dist/credentials/tenant-guard.js +53 -0
  146. package/dist/credentials/tenant-guard.js.map +1 -0
  147. package/dist/credentials/tty-guard.d.ts +19 -0
  148. package/dist/credentials/tty-guard.d.ts.map +1 -0
  149. package/dist/credentials/tty-guard.js +47 -0
  150. package/dist/credentials/tty-guard.js.map +1 -0
  151. package/dist/db/apply-schema.d.ts +144 -0
  152. package/dist/db/apply-schema.d.ts.map +1 -0
  153. package/dist/db/apply-schema.js +359 -0
  154. package/dist/db/apply-schema.js.map +1 -0
  155. package/dist/db/sql-file.d.ts +104 -0
  156. package/dist/db/sql-file.d.ts.map +1 -0
  157. package/dist/db/sql-file.js +584 -0
  158. package/dist/db/sql-file.js.map +1 -0
  159. package/dist/instance/provision.d.ts +89 -0
  160. package/dist/instance/provision.d.ts.map +1 -0
  161. package/dist/instance/provision.js +139 -0
  162. package/dist/instance/provision.js.map +1 -0
  163. package/dist/instance/zero-store.d.ts +47 -0
  164. package/dist/instance/zero-store.d.ts.map +1 -0
  165. package/dist/instance/zero-store.js +91 -0
  166. package/dist/instance/zero-store.js.map +1 -0
  167. package/dist/main.d.ts +3 -0
  168. package/dist/main.d.ts.map +1 -0
  169. package/dist/main.js +46 -0
  170. package/dist/main.js.map +1 -0
  171. package/dist/skill/assets.d.ts +38 -0
  172. package/dist/skill/assets.d.ts.map +1 -0
  173. package/dist/skill/assets.js +111 -0
  174. package/dist/skill/assets.js.map +1 -0
  175. package/dist/skill/hint.d.ts +39 -0
  176. package/dist/skill/hint.d.ts.map +1 -0
  177. package/dist/skill/hint.js +109 -0
  178. package/dist/skill/hint.js.map +1 -0
  179. package/dist/skill/hosts.d.ts +21 -0
  180. package/dist/skill/hosts.d.ts.map +1 -0
  181. package/dist/skill/hosts.js +38 -0
  182. package/dist/skill/hosts.js.map +1 -0
  183. package/dist/skill/installer.d.ts +88 -0
  184. package/dist/skill/installer.d.ts.map +1 -0
  185. package/dist/skill/installer.js +203 -0
  186. package/dist/skill/installer.js.map +1 -0
  187. package/dist/skill/ownership.d.ts +35 -0
  188. package/dist/skill/ownership.d.ts.map +1 -0
  189. package/dist/skill/ownership.js +52 -0
  190. package/dist/skill/ownership.js.map +1 -0
  191. package/package.json +63 -3
  192. package/skills/pxbaas-app-spec/SKILL.md +163 -0
  193. package/skills/pxbaas-app-spec/references/deploy-options.md +157 -0
  194. package/skills/pxbaas-app-spec/references/guardrails-pitfalls.md +89 -0
  195. package/skills/pxbaas-app-spec/references/runtime-contract.md +151 -0
  196. package/skills/pxbaas-app-spec/references/ssr-session.md +121 -0
  197. package/skills/pxbaas-app-spec/references/templates-examples.md +311 -0
  198. package/skills/pxbaas-cli/SKILL.md +171 -0
  199. package/skills/pxbaas-cli/references/api-reference.md +68 -0
  200. package/skills/pxbaas-cli/references/app-deploy.md +170 -0
  201. package/skills/pxbaas-cli/references/auth-and-credentials.md +100 -0
  202. package/skills/pxbaas-cli/references/data-postgrest.md +85 -0
  203. package/skills/pxbaas-cli/references/db-and-meta.md +97 -0
  204. package/skills/pxbaas-cli/references/edge-functions.md +71 -0
  205. package/skills/pxbaas-cli/references/multi-tenant-apps.md +138 -0
  206. package/skills/pxbaas-cli/references/provision.md +86 -0
  207. package/skills/pxbaas-cli/references/safety-and-two-phase.md +49 -0
  208. package/skills/pxbaas-cli/references/storage.md +61 -0
  209. package/skills/pxbaas-cli/references/troubleshooting.md +73 -0
  210. package/templates/webapp/api/index.ts +55 -0
  211. package/templates/webapp/csr-bff/README.md +81 -0
  212. package/templates/webapp/csr-bff/backend/index.ts +168 -0
  213. package/templates/webapp/csr-bff/db/rls.json +13 -0
  214. package/templates/webapp/csr-bff/db/schema.sql +49 -0
  215. package/templates/webapp/csr-bff/frontend/dist/admin.html +101 -0
  216. package/templates/webapp/csr-bff/frontend/dist/article.html +44 -0
  217. package/templates/webapp/csr-bff/frontend/dist/blog.js +206 -0
  218. package/templates/webapp/csr-bff/frontend/dist/blogger.html +53 -0
  219. package/templates/webapp/csr-bff/frontend/dist/index.html +70 -0
  220. package/templates/webapp/csr-bff/frontend/dist/login.html +56 -0
  221. package/templates/webapp/csr-bff/frontend/dist/profile.html +59 -0
  222. package/templates/webapp/csr-bff/frontend/dist/signup.html +78 -0
  223. package/templates/webapp/csr-bff/frontend/dist/style.css +27 -0
  224. package/templates/webapp/manifest.json +299 -0
  225. package/templates/webapp/mpa/about.html +29 -0
  226. package/templates/webapp/mpa/app.js +42 -0
  227. package/templates/webapp/mpa/index.html +42 -0
  228. package/templates/webapp/mpa/index.ts +60 -0
  229. package/templates/webapp/mpa/style.css +42 -0
  230. package/templates/webapp/react-vite/frontend/index.html +12 -0
  231. package/templates/webapp/react-vite/frontend/package-lock.json +1826 -0
  232. package/templates/webapp/react-vite/frontend/package.json +23 -0
  233. package/templates/webapp/react-vite/frontend/src/App.tsx +34 -0
  234. package/templates/webapp/react-vite/frontend/src/main.tsx +13 -0
  235. package/templates/webapp/react-vite/frontend/src/style.css +46 -0
  236. package/templates/webapp/react-vite/frontend/tsconfig.json +20 -0
  237. package/templates/webapp/react-vite/frontend/vite.config.ts +10 -0
  238. package/templates/webapp/react-vite-hono/backend/index.ts +21 -0
  239. package/templates/webapp/react-vite-hono/frontend/index.html +12 -0
  240. package/templates/webapp/react-vite-hono/frontend/package-lock.json +1826 -0
  241. package/templates/webapp/react-vite-hono/frontend/package.json +23 -0
  242. package/templates/webapp/react-vite-hono/frontend/src/App.tsx +47 -0
  243. package/templates/webapp/react-vite-hono/frontend/src/main.tsx +13 -0
  244. package/templates/webapp/react-vite-hono/frontend/src/style.css +66 -0
  245. package/templates/webapp/react-vite-hono/frontend/tsconfig.json +20 -0
  246. package/templates/webapp/react-vite-hono/frontend/vite.config.ts +10 -0
  247. package/templates/webapp/spa/index.ts +119 -0
  248. package/templates/webapp/ssr-auth/backend/index.ts +133 -0
  249. package/templates/webapp/ssr-auth/frontend/dist/index.html +15 -0
  250. package/templates/webapp/ssr-blog/README.md +111 -0
  251. package/templates/webapp/ssr-blog/backend/index.ts +764 -0
  252. package/templates/webapp/ssr-blog/db/rls.json +13 -0
  253. package/templates/webapp/ssr-blog/db/schema.sql +51 -0
  254. package/templates/webapp/ssr-blog/frontend/dist/index.html +39 -0
  255. package/templates/webapp/ssr-frameworks/README.md +64 -0
  256. package/templates/webapp/ssr-frameworks/backend/index.ts +241 -0
  257. package/templates/webapp/ssr-frameworks/frontend/dist/index.html +31 -0
  258. package/templates/webapp/ssr-hono/backend/index.ts +92 -0
  259. package/templates/webapp/ssr-hono/frontend/dist/index.html +12 -0
  260. package/templates/webapp/ssr-shop/README.md +80 -0
  261. package/templates/webapp/ssr-shop/backend/index.ts +491 -0
  262. package/templates/webapp/ssr-shop/db/rls.json +16 -0
  263. package/templates/webapp/ssr-shop/db/schema.sql +90 -0
  264. package/templates/webapp/ssr-shop/frontend/dist/index.html +12 -0
  265. package/templates/webapp/vanilla-static/frontend/app.js +6 -0
  266. package/templates/webapp/vanilla-static/frontend/index.html +19 -0
  267. package/templates/webapp/vanilla-static/frontend/style.css +43 -0
@@ -0,0 +1,81 @@
1
+ # {{APP_NAME}} — 前后端分离(CSR + BFF)参考模板
2
+
3
+ **形态**:7 个真实静态页(`index/article/blogger/login/signup/admin/profile`)+ `style.css` + `blog.js`,由 proxy 静态分支直发(**不启 worker**);所有数据/认证走同源 `./api/*` 打到 Deno BFF worker,worker 再转发到实例**内网**的 `/rest/v1`、`/auth/v1`。
4
+ **会话模型**:`proxy-injected` —— 浏览器不直连 Supabase;登录经平台 `./_session/login` 建立 per-mount HttpOnly cookie,proxy 校验后向 worker 注入 `X-App-User-*`,worker 用 `X-App-User-Token` 作为下游 `Authorization`,由 GoTrue/MysqlREST 的 RLS 做最终裁决。
5
+
6
+ ## 为什么 BFF 不能靠"透传浏览器 Authorization"
7
+
8
+ `/apps/{mount}/api/*` 走 `TRUSTED_INTERNAL` 凭据策略,proxy 会剥掉客户端的
9
+ `authorization / cookie / apikey / x-api-key`。实测判据:同一 token 直连公网 `/auth/v1/user` 得 200,
10
+ 经 BFF 透传却得 `403 bad_jwt: invalid claim: missing sub claim` —— 说明中间层剥了头,worker 恒回落 anon。
11
+ 因此本模板的身份来源**只有**注入头:
12
+
13
+ ```ts
14
+ const injected = req.headers.get("x-app-user-token") || ""; // 方案 C
15
+ const userAuth = injected || incomingAuth; // 兼容 /functions 入口的透传
16
+ ```
17
+
18
+ 前端侧对应:登录/注册成功后追加 `fetch("./_session/login")` 建立 cookie,`clearToken()` 时
19
+ `fetch("./_session/logout", { keepalive: true })`(`keepalive` 保证跳转后请求仍能完成);
20
+ 端点不存在时静默忽略,以兼容旧实例与 `/functions` 入口。
21
+
22
+ ## 目录
23
+
24
+ ```
25
+ {{APP_NAME}}/
26
+ ├── app.json # schemaVersion 2;fallback=null(页面都是真实静态文件)
27
+ ├── backend/index.ts # BFF worker:/api/health /api/config /api/me /api/rest/* /api/auth/*
28
+ ├── frontend/dist/ # 7 个页面 + style.css + blog.js(相对路径引用)
29
+ ├── db/schema.sql # 幂等数据层:posts + 5 条 RLS + 3 篇种子
30
+ └── README.md
31
+ ```
32
+
33
+ `fallback: null` 意味着静态未命中即 404(不落到 worker);BFF 只经 `/api/*` 访问,因此不需要 SSR 探针。
34
+
35
+ ## 部署前必须做的一件事:填 DEPLOY CONFIG
36
+
37
+ `backend/index.ts` 顶部两个常量默认为空;留空时除 `/api/health` 外一律返回
38
+ `503 {"error":"deploy config missing"}`(刻意设计:绝不回落到任何真实实例)。
39
+
40
+ ```ts
41
+ const INTERNAL_BASE = ""; // 目标实例内网基址(公网 host 去掉 http./https. 中缀)
42
+ const ANON_KEY = ""; // 单租户=实例 anon;多租户=该租户 anon
43
+ ```
44
+
45
+ worker 只用 anon 作 Kong 的 `apikey`,并把用户 JWT 原样带给下游;**从不使用 service_role**,
46
+ 因此它拿到的权限不会超过调用者本人。
47
+
48
+ ## 数据层
49
+
50
+ ```bash
51
+ sed 's/__SCHEMA__/public/g' db/schema.sql # 单租户
52
+ sed 's/__SCHEMA__/tenant_acme/g' db/schema.sql # 多租户
53
+ ```
54
+
55
+ RLS 元数据写**中心库** `supabase._mysqlrest_rls_*`,`table_schema` = 上面的 `__SCHEMA__`;写错静默失效。
56
+
57
+ ## 部署
58
+
59
+ ```bash
60
+ pxbaas app deploy {{APP_NAME}} --dir . # appName 必须与 app.json 的 name 一致
61
+ ```
62
+
63
+ 走 curl Management API 时:静态文件必须用显式嵌套 `filename=frontend/dist/<name>`,否则路径被压平导致 publish 报缺文件。
64
+
65
+ ## 验收清单
66
+
67
+ | # | 检查 | 期望 |
68
+ |---|---|---|
69
+ | 1 | 7 个页面 + `style.css` + `blog.js` | 全 200,content-type 正确(`text/css` / `text/javascript`) |
70
+ | 2 | `GET /apps/{m}/api/health` | `{"ok":true,"via":"bff"}`(未配置 DEPLOY CONFIG 时也应可用) |
71
+ | 3 | `GET /apps/{m}/api/config` | 返回的 `anon` **等于目标实例/租户的 anon**(证明参数化生效,没串到别的实例) |
72
+ | 4 | `GET /apps/{m}/api/rest/posts` 匿名 | 只见 published |
73
+ | 5 | `POST /apps/{m}/_session/login` | 200 + `Set-Cookie: pxbaas-app-session; Path=/apps/{m}/; HttpOnly` |
74
+ | 6 | **只带 cookie**(不带 Authorization)`GET /api/me` | 返回完整用户对象 |
75
+ | 7 | 带 cookie 发文(`author_id`=自己 / 他人) | 201 / **403 PGRST302** |
76
+ | 8 | 带 cookie 读 posts | published + 自己的 draft |
77
+ | 9 | `POST /api/../_session/logout` 后 `GET /api/me` | `{"user":null}` |
78
+ | 10 | 双用户交替请求 | 各自只见自己(无共享 jar 串味) |
79
+
80
+ > 注意:`logout` 页靠内联 JS 调 `./_session/logout`,**curl 不执行 JS**,用 curl 复验时要直接打 proxy 的退出端点。
81
+ > 另:在容器内以 `Host: 127.0.0.1:8000` 打 `/apps/*` 会得到 `APP_HOST_REJECTED`,属预期;验证请从公网侧发起。
@@ -0,0 +1,168 @@
1
+ // mpa-blog BFF worker (front/back separated).
2
+ //
3
+ // browser ──./api/*──> THIS worker ──internal DNS──> /rest/v1 + /auth/v1 ──> db / GoTrue
4
+ //
5
+ // The sandbox CANNOT reach the instance's public SUPABASE_URL (egress-filtered), but CAN reach
6
+ // the internal/private DNS entry (pxsp-<id>.pxbaas...:8000 -> 10.x VPC VIP). So the worker proxies
7
+ // browser calls to the internal Kong base. Credential model = JWT PASSTHROUGH: the browser's
8
+ // Authorization header (GoTrue user JWT) is forwarded verbatim so MysqlREST RLS and GoTrue enforce
9
+ // per-user rules server-side. The worker injects only the global `apikey` (anon) for Kong key-auth
10
+ // and NEVER uses service_role, so it gains no privilege beyond the calling user.
11
+ //
12
+ // Static pages/assets are served by the edge-function-proxy static branch (app.json mode=mpa);
13
+ // this worker only handles /api/*.
14
+
15
+ const CORS: Record<string, string> = {
16
+ "Access-Control-Allow-Origin": "*",
17
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, PUT, DELETE, OPTIONS",
18
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, Prefer, Range, X-Tenant-Id",
19
+ "Access-Control-Expose-Headers": "Content-Range, Prefer, Location",
20
+ };
21
+
22
+ function json(body: unknown, status = 200): Response {
23
+ return new Response(JSON.stringify(body), {
24
+ status,
25
+ headers: { "Content-Type": "application/json; charset=utf-8", ...CORS },
26
+ });
27
+ }
28
+
29
+ // ─── DEPLOY CONFIG(平台注入优先;缺失则除 /api/health 外一律 503,绝不回落到任何真实实例)───
30
+ // env 契约(MR #30497903 起):平台按三键白名单向 App candidate 注入 SUPABASE_INTERNAL_URL /
31
+ // SUPABASE_ANON_KEY / SUPABASE_URL,spec-lint 的 deno-env-not-allowed 放行这三键。本模板直读
32
+ // SUPABASE_INTERNAL_URL(数据面唯一可达基址)与 SUPABASE_ANON_KEY(数据面凭据),无需手填明文 key。
33
+ // 若目标实例 sandbox 镜像尚未含该 MR,两值为空 → configError 明确 503,绝不静默连到别的实例/租户。
34
+ // INTERNAL_BASE —— 平台注入的内网基址(公网 host 去掉 http./https. 中缀);公网 VIP 在沙箱内因
35
+ // egress 过滤不可达(非 hairpin),切勿改填公网地址。
36
+ // ANON_KEY —— 单租户=实例 anon key;多租户=**该租户** anon key(自带 tenant_id claim,
37
+ // REST/Auth 据此路由,无需 X-Tenant-Id)。anon 是公开值;service_role 严禁入 worker。
38
+ const INTERNAL_BASE = Deno.env.get("SUPABASE_INTERNAL_URL") ?? "";
39
+ const ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? "";
40
+
41
+ function configError(): string | null {
42
+ if (!INTERNAL_BASE) return "SUPABASE_INTERNAL_URL not injected: the sandbox image predates MR #30497903; the platform must inject the internal base before this BFF can reach the data plane";
43
+ if (!ANON_KEY) return "SUPABASE_ANON_KEY not injected: the sandbox image predates MR #30497903; the platform must inject the anon key";
44
+ return null;
45
+ }
46
+
47
+ function anonKey(): string {
48
+ return ANON_KEY;
49
+ }
50
+
51
+ /** 服务端转发唯一合法通道:内网基址(见上方 DEPLOY CONFIG)。 */
52
+ function internalBase(): string {
53
+ return INTERNAL_BASE.replace(/\/+$/, "");
54
+ }
55
+
56
+ /** Extract the segments after the "api" marker, robust to mount/dispatch prefix differences. */
57
+ function apiTail(pathname: string): string[] {
58
+ // Mount/webapp dispatch hands the worker the candidate-slug-prefixed logical path
59
+ // (/{app_<uuid>_r_<rev>}/...); strip that first, then locate the "api" marker.
60
+ const norm = pathname.replace(/^\/app_[^/]+/, "") || pathname;
61
+ const segs = norm.split("/").filter(Boolean);
62
+ const i = segs.lastIndexOf("api");
63
+ if (i >= 0) return segs.slice(i + 1);
64
+ return segs; // fallback: whole path
65
+ }
66
+
67
+ async function readBody(req: Request): Promise<BodyInit | null> {
68
+ if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return null;
69
+ try {
70
+ return await req.arrayBuffer();
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /** Forward a request to the internal Kong base, injecting apikey and passing through the user's JWT. */
77
+ async function proxy(req: Request, internalPath: string, search: string): Promise<Response> {
78
+ const base = internalBase();
79
+ const url = base + internalPath + (search || "");
80
+ const anon = anonKey();
81
+ // /apps/{mount}/api/* runs under TRUSTED_INTERNAL, which strips the browser's
82
+ // Authorization/Cookie before the worker ever sees them, so plain JWT passthrough
83
+ // cannot work on that entry. There the proxy validates the per-mount session
84
+ // cookie and injects the trusted X-App-User-Token instead. Prefer the injected
85
+ // token, fall back to a passed-through Authorization (/functions entry), then to
86
+ // anon for public reads.
87
+ const injected = req.headers.get("x-app-user-token") || "";
88
+ const incomingAuth = req.headers.get("Authorization") || "";
89
+ const userAuth = injected || incomingAuth;
90
+ const authValue = userAuth
91
+ ? (/^Bearer\s+/i.test(userAuth) ? userAuth : `Bearer ${userAuth}`)
92
+ : (anon ? `Bearer ${anon}` : "");
93
+
94
+ const headers: Record<string, string> = {
95
+ "apikey": anon,
96
+ // Forward the caller's JWT (RLS/GoTrue act on it); fall back to anon for public reads.
97
+ "Authorization": authValue,
98
+ "Accept": req.headers.get("Accept") || "application/json",
99
+ };
100
+ const ct = req.headers.get("Content-Type");
101
+ if (ct) headers["Content-Type"] = ct;
102
+ const prefer = req.headers.get("Prefer");
103
+ if (prefer) headers["Prefer"] = prefer;
104
+
105
+ const body = await readBody(req);
106
+ let upstream: Response;
107
+ try {
108
+ upstream = await fetch(url, { method: req.method, headers, body });
109
+ } catch (e) {
110
+ const msg = String((e as any)?.message ?? e);
111
+ return json({ error: "upstream_unreachable", detail: msg, internalBase: base, internalPath }, 502);
112
+ }
113
+
114
+ const respHeaders: Record<string, string> = { ...CORS };
115
+ const rct = upstream.headers.get("Content-Type");
116
+ if (rct) respHeaders["Content-Type"] = rct;
117
+ for (const h of ["Content-Range", "Prefer", "Location"]) {
118
+ const v = upstream.headers.get(h);
119
+ if (v) respHeaders[h] = v;
120
+ }
121
+ // 204/205/304 are "null body" statuses: constructing a Response with a body throws.
122
+ // PostgREST returns 204 for DELETE/PATCH without Prefer: return=representation.
123
+ if (upstream.status === 204 || upstream.status === 205 || upstream.status === 304) {
124
+ return new Response(null, { status: upstream.status, headers: respHeaders });
125
+ }
126
+ const payload = await upstream.arrayBuffer().catch(() => new ArrayBuffer(0));
127
+ return new Response(payload, { status: upstream.status, headers: respHeaders });
128
+ }
129
+
130
+ Deno.serve(async (req: Request): Promise<Response> => {
131
+ if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
132
+
133
+ const url = new URL(req.url);
134
+ const tail = apiTail(url.pathname);
135
+ const head = (tail[0] || "").toLowerCase();
136
+ const search = url.search || "";
137
+
138
+ // ---- worker-local endpoints ----
139
+ if (head === "health") return json({ ok: true, now: new Date().toISOString(), via: "bff" });
140
+ const cfgErr = configError();
141
+ if (cfgErr) return json({ error: "deploy config missing", detail: cfgErr }, 503);
142
+ if (head === "config") return json({ anon: anonKey(), mode: "bff" });
143
+ if (head === "me") {
144
+ // Server-side session check via internal GoTrue, using the caller's JWT.
145
+ const r = await proxy(req, "/auth/v1/user", "");
146
+ if (r.status !== 200) return json({ user: null });
147
+ try {
148
+ const u = await r.clone().json();
149
+ return json({ user: u && u.id ? u : null });
150
+ } catch {
151
+ return json({ user: null });
152
+ }
153
+ }
154
+
155
+ // ---- generic authenticated proxies ----
156
+ // /api/rest/<rest...> -> internal /rest/v1/<rest...>
157
+ if (head === "rest") {
158
+ const sub = tail.slice(1).join("/");
159
+ return proxy(req, "/rest/v1/" + sub, search);
160
+ }
161
+ // /api/auth/<auth...> -> internal /auth/v1/<auth...>
162
+ if (head === "auth") {
163
+ const sub = tail.slice(1).join("/");
164
+ return proxy(req, "/auth/v1/" + sub, search);
165
+ }
166
+
167
+ return json({ error: "not found", path: url.pathname, tail }, 404);
168
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "$comment": "Declarative RLS for the CSR/BFF blog webapp. Applied by `pxbaas app db-init` (single-tenant AND multi-tenant). table_schema is NEVER declared here: single-tenant binds it to --schema (default public); multi-tenant binds it server-side to the tenant db.",
3
+ "tables": [
4
+ { "table": "posts", "rlsEnabled": true, "rlsForced": false }
5
+ ],
6
+ "policies": [
7
+ { "table": "posts", "name": "posts_select_anon", "role": "anon", "command": "SELECT", "using": "`status` = 'published'", "check": null, "permissive": true },
8
+ { "table": "posts", "name": "posts_select_auth", "role": "authenticated", "command": "SELECT", "using": "`status` = 'published' OR author_id = {{request.jwt.claim.sub}}", "check": null, "permissive": true },
9
+ { "table": "posts", "name": "posts_insert_auth", "role": "authenticated", "command": "INSERT", "using": null, "check": "author_id = {{request.jwt.claim.sub}}", "permissive": true },
10
+ { "table": "posts", "name": "posts_update_auth", "role": "authenticated", "command": "UPDATE", "using": "author_id = {{request.jwt.claim.sub}}", "check": "author_id = {{request.jwt.claim.sub}}", "permissive": true },
11
+ { "table": "posts", "name": "posts_delete_auth", "role": "authenticated", "command": "DELETE", "using": "author_id = {{request.jwt.claim.sub}}", "check": null, "permissive": true }
12
+ ]
13
+ }
@@ -0,0 +1,49 @@
1
+ -- =============================================================================
2
+ -- multi-page-blog-csr — 数据层(幂等,可重复执行)
3
+ --
4
+ -- 用法:把 __SCHEMA__ 替换为目标库名后逐条执行
5
+ -- · 单租户实例:__SCHEMA__ = public
6
+ -- · 多租户实例:__SCHEMA__ = tenant_<tenant_id>
7
+ -- sed 's/__SCHEMA__/public/g' db/schema.sql
8
+ --
9
+ -- 执行通道(推荐):`pxbaas app db-init <name>` —— 单/多租户通用:
10
+ -- 单租户 __SCHEMA__→public 经 /pg/query;多租户 __SCHEMA__ 去限定经 /api/tenant-ddl
11
+ -- 用 per-tenant service_role key 执行(GRANT 仅本租户库)。本文件只含 DDL + 种子;
12
+ -- RLS 声明在同目录 db/rls.json,由 db-init 一并应用。
13
+ --
14
+ -- ⚠️ RLS 元数据落点(写错静默失效=RLS 关闭):永远写中心库 supabase._mysqlrest_rls_*;
15
+ -- db-init 自动绑定 table_schema(单租户=--schema,多租户=服务端强绑),勿手写库名。
16
+ -- =============================================================================
17
+
18
+ CREATE TABLE IF NOT EXISTS `__SCHEMA__`.`posts` (
19
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
20
+ title VARCHAR(200) NOT NULL,
21
+ content MEDIUMTEXT,
22
+ author_id VARCHAR(64) NOT NULL,
23
+ author_name VARCHAR(100) DEFAULT '',
24
+ status VARCHAR(16) DEFAULT 'draft',
25
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
26
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
27
+ );
28
+
29
+ -- ---- RLS 注册与策略:声明在同目录 db/rls.json,由 `pxbaas app db-init` 应用 ----
30
+ -- anon 只读 published;authenticated 读 published 或本人;写/改/删限本人(author_id = jwt.sub)。
31
+
32
+ -- ---- 种子文章(3 篇 published,幂等)----
33
+ INSERT INTO `__SCHEMA__`.`posts` (title, content, author_id, author_name, status)
34
+ SELECT * FROM (
35
+ SELECT '前后端分离的博客长什么样' a,
36
+ '静态多页 + BFF worker:浏览器只跟自己同源的 ./api/* 说话,worker 再转发到内网 REST/Auth,凭据由平台注入的身份头承载,RLS 在服务端兜底。' b,
37
+ 'demo-author' c, '架构笔记' d, 'published' e
38
+ UNION ALL SELECT '为什么浏览器不该直连数据库网关',
39
+ '直连意味着 anon key 与用户 JWT 都暴露在页面里,跨域与凭据边界都变复杂;走同源 BFF 后前端只需相对路径,权限判定全部落在服务端。',
40
+ 'demo-author', '安全', 'published'
41
+ UNION ALL SELECT 'CSR 与 SSR 的取舍',
42
+ 'CSR 首屏靠 JS 拉数据,利于交互复杂、SEO 要求不高的页面;SSR 首屏即内容,利于分享与收录。两者可共存于同一套数据层与 RLS 策略之上。',
43
+ 'demo-author', '随笔', 'published'
44
+ ) t WHERE NOT EXISTS (SELECT 1 FROM `__SCHEMA__`.`posts` WHERE title='前后端分离的博客长什么样');
45
+
46
+ -- ---- 验收 ----
47
+ -- 1) anon 读 posts → 只见 3 篇 published;
48
+ -- 2) 用户 token 写 posts(author_id=自己)→ 201;伪造他人 author_id → 403 PGRST302;
49
+ -- 3) 用户读 posts → 3 篇 published + 自己的 draft。
@@ -0,0 +1,101 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>MPA Blog — 博主管理</title>
7
+ <link rel="stylesheet" href="./style.css">
8
+ </head>
9
+ <body>
10
+ <nav><a href="./index.html">← 返回广场</a><a href="./profile.html">我的资料</a><span class="spacer"></span><span id="who" class="muted"></span></nav>
11
+ <div id="gate"></div>
12
+ <div id="admin" style="display:none">
13
+ <h1>写文章</h1>
14
+ <div class="card">
15
+ <input id="title" placeholder="标题" maxlength="200">
16
+ <textarea id="content" rows="5" placeholder="正文(text/markdown,渲染时转义)"></textarea>
17
+ <select id="status"><option value="draft">draft(草稿)</option><option value="published">published(发布)</option></select>
18
+ <button id="create">创建</button>
19
+ <div id="msg" class="muted"></div>
20
+ </div>
21
+ <h1>我的文章</h1>
22
+ <div id="list"><div class="muted">加载中…</div></div>
23
+ </div>
24
+ <script src="./blog.js"></script>
25
+ <script>
26
+ var gate = document.getElementById('gate');
27
+ var user = null;
28
+ BLOG.init().then(function () {
29
+ if (BLOG.isSharedView()) {
30
+ gate.innerHTML = '<div class="banner">分享只读视图:管理功能已禁用。请通过函数原始 URL 打开本页进行管理。</div>' +
31
+ '<div class="muted">下面仅展示公开可见文章。</div>';
32
+ return BLOG.loadPosts().then(renderList);
33
+ }
34
+ return BLOG.me().then(function (m) {
35
+ if (!m.user) { location.replace('./login.html'); return; }
36
+ user = m.user;
37
+ document.getElementById('who').textContent = user.email || user.id;
38
+ document.getElementById('admin').style.display = '';
39
+ document.getElementById('create').onclick = create;
40
+ // 管理列表仅展示本人文章(他人已发布文章在首页/分享视图可见,RLS 服务端强制)
41
+ return BLOG.loadPosts().then(function (rows) {
42
+ renderList(rows.filter(function (p) { return p.author_id === user.id; }));
43
+ });
44
+ });
45
+ }).catch(function (e) {
46
+ gate.innerHTML = '<div class="error">初始化失败: ' + BLOG.esc(e) + '</div>';
47
+ });
48
+
49
+ function renderList(rows) {
50
+ var el = document.getElementById('list');
51
+ if (!rows.length) { el.innerHTML = '<div class="muted">暂无文章。</div>'; return; }
52
+ el.innerHTML = rows.map(function (p) {
53
+ var ops = '';
54
+ if (user) {
55
+ ops = '<span>' +
56
+ (p.status === 'draft'
57
+ ? '<button class="ghost" data-act="publish" data-id="' + p.id + '">发布</button> '
58
+ : '<button class="ghost" data-act="unpublish" data-id="' + p.id + '">转草稿</button> ') +
59
+ '<button class="danger" data-act="delete" data-id="' + p.id + '">删除</button></span>';
60
+ }
61
+ return '<div class="card"><div class="row"><h2><a href="./article.html#id=' + encodeURIComponent(p.id) + '">' +
62
+ BLOG.esc(p.title) + '</a></h2><span class="badge ' + BLOG.esc(p.status) + '">' + BLOG.esc(p.status) + '</span></div>' +
63
+ '<div class="row muted"><span>#' + BLOG.esc(p.id) + ' · ' + BLOG.esc(BLOG.fmtDate(p.created_at)) + '</span>' + ops + '</div></div>';
64
+ }).join('');
65
+ Array.prototype.forEach.call(el.querySelectorAll('button[data-act]'), function (b) {
66
+ b.onclick = function () { act(b.getAttribute('data-act'), b.getAttribute('data-id')); };
67
+ });
68
+ }
69
+
70
+ function create() {
71
+ var msg = document.getElementById('msg');
72
+ var body = {
73
+ title: document.getElementById('title').value.trim(),
74
+ content: document.getElementById('content').value,
75
+ status: document.getElementById('status').value,
76
+ author_id: user.id,
77
+ author_name: BLOG.displayName() || String(user.email || '').split('@')[0] || '匿名博主',
78
+ };
79
+ if (!body.title) { msg.className = 'error'; msg.textContent = '标题不能为空'; return; }
80
+ fetch(BLOG.rest(), { method: 'POST', headers: BLOG.headers(true), body: JSON.stringify(body) })
81
+ .then(function (r) {
82
+ if (!r.ok) return r.text().then(function (t) { throw new Error('HTTP ' + r.status + ' ' + t); });
83
+ msg.className = 'muted'; msg.textContent = '已创建';
84
+ document.getElementById('title').value = ''; document.getElementById('content').value = '';
85
+ return BLOG.loadPosts().then(renderList);
86
+ }).catch(function (e) { msg.className = 'error'; msg.textContent = String(e); });
87
+ }
88
+
89
+ function act(kind, id) {
90
+ var patch = kind === 'publish' ? { status: 'published' } : kind === 'unpublish' ? { status: 'draft' } : null;
91
+ var p = patch
92
+ ? fetch(BLOG.rest() + '?id=eq.' + encodeURIComponent(id), { method: 'PATCH', headers: BLOG.headers(true), body: JSON.stringify(patch) })
93
+ : fetch(BLOG.rest() + '?id=eq.' + encodeURIComponent(id), { method: 'DELETE', headers: BLOG.headers(true) });
94
+ p.then(function (r) {
95
+ if (!r.ok) return r.text().then(function (t) { throw new Error('HTTP ' + r.status + ' ' + t); });
96
+ return BLOG.loadPosts().then(renderList);
97
+ }).catch(function (e) { alert('操作失败: ' + e); });
98
+ }
99
+ </script>
100
+ </body>
101
+ </html>
@@ -0,0 +1,44 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>MPA Blog — 文章详情</title>
7
+ <link rel="stylesheet" href="./style.css">
8
+ </head>
9
+ <body>
10
+ <nav><a href="./index.html">← 返回广场</a><span class="spacer"></span><button id="copylink" class="ghost">复制分享链接</button></nav>
11
+ <div id="copymsg" class="muted"></div>
12
+ <div id="view"><div class="muted">加载中…</div></div>
13
+ <script src="./blog.js"></script>
14
+ <script>
15
+ document.getElementById('copylink').onclick = function () {
16
+ var msg = document.getElementById('copymsg');
17
+ BLOG.copyText(location.href).then(function (ok) {
18
+ msg.textContent = ok ? '已复制:' + location.href : '复制失败,请手动复制地址栏:' + location.href;
19
+ if (!ok) prompt('复制分享链接:', location.href);
20
+ });
21
+ };
22
+ var raw = location.hash.replace(/^#/, '');
23
+ var id = raw.indexOf('id=') === 0 ? raw.slice(3) : raw;
24
+ var el = document.getElementById('view');
25
+ if (!/^\d+$/.test(id)) {
26
+ el.innerHTML = '<div class="error">缺少文章 ID(正确形式:article.html#id=1)</div>';
27
+ } else {
28
+ BLOG.init().then(function () { return BLOG.loadPost(id); }).then(function (p) {
29
+ if (!p) { el.innerHTML = '<div class="error">文章不存在或不可见(未发布且非作者)。</div>'; return; }
30
+ if (BLOG.isSharedView() && p.status !== 'published') {
31
+ el.innerHTML = '<div class="error">该文章尚未发布,分享只读视图不可见。</div>'; return;
32
+ }
33
+ el.innerHTML =
34
+ '<h1>' + BLOG.esc(p.title) + ' <span class="badge ' + BLOG.esc(p.status) + '">' + BLOG.esc(p.status) + '</span></h1>' +
35
+ '<div class="muted">博主: ' + BLOG.esc(BLOG.authorLabel(p)) + ' · #' + BLOG.esc(p.id) + ' · ' +
36
+ BLOG.esc(BLOG.fmtDate(p.created_at)) + '</div>' +
37
+ '<p class="content">' + BLOG.esc(p.content || '') + '</p>';
38
+ }).catch(function (e) {
39
+ el.innerHTML = '<div class="error">加载失败: ' + BLOG.esc(e) + '</div>';
40
+ });
41
+ }
42
+ </script>
43
+ </body>
44
+ </html>