@polardbx/pxbaas 0.1.0-beta.1 → 0.1.0-beta.3

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 (58) hide show
  1. package/README.md +179 -0
  2. package/dist/app/preflight.d.ts +12 -1
  3. package/dist/app/preflight.d.ts.map +1 -1
  4. package/dist/app/preflight.js +28 -5
  5. package/dist/app/preflight.js.map +1 -1
  6. package/dist/app/spec-lint.d.ts.map +1 -1
  7. package/dist/app/spec-lint.js +16 -0
  8. package/dist/app/spec-lint.js.map +1 -1
  9. package/dist/commands/app/index.d.ts.map +1 -1
  10. package/dist/commands/app/index.js +253 -12
  11. package/dist/commands/app/index.js.map +1 -1
  12. package/dist/commands/db/index.d.ts +1 -0
  13. package/dist/commands/db/index.d.ts.map +1 -1
  14. package/dist/commands/db/index.js +74 -27
  15. package/dist/commands/db/index.js.map +1 -1
  16. package/dist/commands/gen/index.d.ts.map +1 -1
  17. package/dist/commands/gen/index.js +13 -10
  18. package/dist/commands/gen/index.js.map +1 -1
  19. package/dist/commands/login/index.d.ts.map +1 -1
  20. package/dist/commands/login/index.js +9 -34
  21. package/dist/commands/login/index.js.map +1 -1
  22. package/dist/commands/profile/index.d.ts.map +1 -1
  23. package/dist/commands/profile/index.js +3 -0
  24. package/dist/commands/profile/index.js.map +1 -1
  25. package/dist/credentials/index.d.ts +2 -0
  26. package/dist/credentials/index.d.ts.map +1 -1
  27. package/dist/credentials/index.js +2 -0
  28. package/dist/credentials/index.js.map +1 -1
  29. package/dist/db/apply-schema.d.ts +98 -11
  30. package/dist/db/apply-schema.d.ts.map +1 -1
  31. package/dist/db/apply-schema.js +247 -80
  32. package/dist/db/apply-schema.js.map +1 -1
  33. package/dist/db/introspect.d.ts +54 -0
  34. package/dist/db/introspect.d.ts.map +1 -0
  35. package/dist/db/introspect.js +140 -0
  36. package/dist/db/introspect.js.map +1 -0
  37. package/dist/db/sql-file.d.ts +55 -0
  38. package/dist/db/sql-file.d.ts.map +1 -1
  39. package/dist/db/sql-file.js +417 -0
  40. package/dist/db/sql-file.js.map +1 -1
  41. package/package.json +2 -2
  42. package/skills/pxbaas-app-spec/references/runtime-contract.md +1 -1
  43. package/skills/pxbaas-app-spec/references/templates-examples.md +5 -2
  44. package/skills/pxbaas-cli/SKILL.md +7 -4
  45. package/skills/pxbaas-cli/references/app-deploy.md +29 -2
  46. package/skills/pxbaas-cli/references/auth-and-credentials.md +14 -2
  47. package/skills/pxbaas-cli/references/data-postgrest.md +15 -0
  48. package/skills/pxbaas-cli/references/db-and-meta.md +12 -0
  49. package/skills/pxbaas-cli/references/multi-tenant-apps.md +35 -4
  50. package/templates/webapp/csr-bff/db/rls.json +13 -0
  51. package/templates/webapp/csr-bff/db/schema.sql +8 -28
  52. package/templates/webapp/manifest.json +6 -3
  53. package/templates/webapp/react-vite-hono/backend/index.ts +21 -4
  54. package/templates/webapp/ssr-blog/db/rls.json +13 -0
  55. package/templates/webapp/ssr-blog/db/schema.sql +8 -28
  56. package/templates/webapp/ssr-hono/backend/index.ts +50 -31
  57. package/templates/webapp/ssr-shop/db/rls.json +16 -0
  58. package/templates/webapp/ssr-shop/db/schema.sql +12 -42
@@ -1,6 +1,6 @@
1
1
  # Credentials, Environment & User Management
2
2
 
3
- Covers: `login` (human-only) · `logout` · `whoami` · `status` · `doctor` · `profile` · `auth users`.
3
+ Covers: `login` (human-only) · `profile import` (agent-runnable, env-file) · `logout` · `whoami` · `status` · `doctor` · `profile` · `auth users`.
4
4
 
5
5
  ## login — agents must NEVER run this
6
6
 
@@ -11,7 +11,18 @@ pxbaas login --endpoint <url> --tenant-id <id> --anon-key-stdin --service-key-st
11
11
  - **Requires an interactive TTY** (it prompts via inquirer); agents cannot complete it. **Do not attempt.**
12
12
  - Keys are read from stdin via `--anon-key-stdin` / `--service-key-stdin` so they never land in shell history.
13
13
  - Credentials are stored in the OS keyring (keytar), falling back to a `0600` file when no keyring is available.
14
- - If another command reports `No active profile. Use "pxbaas login" ...` → **ask the user to log in manually**. Do not work around it and do not ask for plaintext keys.
14
+ - If another command reports `No active profile. Use "pxbaas login" ...` → **ask the user to log in manually**, OR — if a Supabase/BaaS `.env` file for the instance is available — bind it non-interactively with `pxbaas profile import <env-file>` (see below; that one IS agent-runnable).
15
+
16
+ ## profile import — bind a single-tenant instance from a `.env` file (agents MAY run this)
17
+
18
+ ```bash
19
+ pxbaas profile import <env-file> --name <profile> [--intranet] --json
20
+ ```
21
+
22
+ - Reads `SUPABASE_URL` (fallback `SUPABASE_PUBLIC_URL` / `SUPABASE_INTRANET_URL`), `SUPABASE_ANON_KEY` (fallback `ANON_KEY` / `GLOBAL_ANON_KEY`) and `SUPABASE_SERVICE_ROLE_KEY` (fallback `SERVICE_ROLE_KEY` / `GLOBAL_SERVICE_ROLE_KEY`) from the file, validates the JWTs (rejects expired keys), then writes the profile + credentials and switches current to it.
23
+ - **Agent-runnable**: keys come from a file, never from argv/shell history, so no TTY is needed. Prefer this over `login` when you already have the instance `.env`.
24
+ - `--intranet` prefers `SUPABASE_INTRANET_URL` (VPC-internal entrypoint) over the public URL. `--name` sets the profile name (default: global `--profile`, else `default`); an existing profile of the same name is **overwritten**.
25
+ - **Single-tenant only for now.** A `MULTI_TENANT_MODE=true` env file is rejected with guidance — its global keys cannot access tenant resources; use `pxbaas login` with per-tenant anon/service keys instead.
15
26
 
16
27
  ## Verify login state (run these first)
17
28
 
@@ -65,6 +76,7 @@ pxbaas logout # clears credentials for the current profile
65
76
  pxbaas profile ls --json # list all profiles
66
77
  pxbaas profile use <name> --json # switch current profile
67
78
  pxbaas profile rm <name> --json # remove a profile (destructive to local config — confirm first)
79
+ pxbaas profile import <env-file> --name <profile> --json # bind a single-tenant instance from a .env file (agent-runnable; see above)
68
80
  ```
69
81
 
70
82
  - You can also pass global `--profile <name>` to target one environment without changing the default.
@@ -83,3 +83,18 @@ pxbaas rest POST "/todos" --data '{"title":"x"}' --role anon --json
83
83
  ```
84
84
 
85
85
  > REST endpoint details, embedding/RPC constraints: fetch `${endpoint}/docs` — see api-reference.md.
86
+
87
+ ## Schema cache after DDL
88
+
89
+ The REST layer (`/rest/v1`) caches each table's column list. After a DDL change
90
+ (`ALTER TABLE ... ADD/DROP/MODIFY COLUMN`, or `CREATE`/`DROP TABLE`) the cache
91
+ re-introspects automatically: immediately where the platform wires DDL to a
92
+ cache reload, otherwise within one refresh interval (~30s). Until then:
93
+
94
+ - `select=*` (and `Prefer: return=representation` bodies) may still omit a
95
+ just-added column. This is a stale column list, not an error — the row is
96
+ returned, only the new field is missing.
97
+ - Workaround: enumerate columns explicitly (`?select=id,room_id,board`) instead
98
+ of `*`, or retry after a few seconds.
99
+
100
+ `pxbaas db add-column` / `db create-table` print a reminder about this.
@@ -19,6 +19,18 @@ pxbaas db query "SELECT count(*) FROM users" --role anon --json
19
19
  { "rows": [ { "count(*)": 42 } ], "rowCount": 1, "fields": [ { "name": "count(*)", "dataType": "BIGINT" } ] }
20
20
  ```
21
21
  - **Gotchas:** `db query` can also run write/DDL SQL. If the statement contains INSERT/UPDATE/DELETE/DROP/ALTER/CREATE, treat it as destructive and confirm with the user first.
22
+ - **Multi-tenant:** with an MT profile (`--tenant-id`), `db query` routes through `/api/tenant-ddl/query` using the per-tenant service_role key and runs on the tenant's OWN database (`/pg/query` is platform-admin only and rejects per-tenant keys). `--role anon` is not applicable there — the tenant-ddl endpoint is service_role-only, so the query always runs as the tenant service_role. For batch DDL + RLS use `db push <file>` or `app db-init <name>` (see app-deploy.md); both are MT-aware.
23
+
24
+ ## db push — apply a SQL file (DDL + RLS + seed)
25
+
26
+ ```bash
27
+ pxbaas db push ./db/schema.sql --schema public --dry-run # preview the plan
28
+ pxbaas db push ./db/schema.sql --yes # apply (stop-on-first-error, no rollback)
29
+ ```
30
+
31
+ - **Flags:** `--schema <name>` (single-tenant `__SCHEMA__` target, default `public`) · `--rls <file>` (declarative `db/rls.json`) · `--role` (default service_role) · `--dry-run` · `--yes` · `--json`
32
+ - Mode-aware: single-tenant runs through `/pg/query`; multi-tenant through `/api/tenant-ddl` (de-qualifies `__SCHEMA__`, diverts RLS to the structured endpoints). Shows the full plan and requires authorization; never rolls back.
33
+ - `app db-init <name>` is the app-bundled convenience wrapper (reads `<appDir>/db/schema.sql` + `db/rls.json`).
22
34
 
23
35
  ## db tables — list tables
24
36
 
@@ -12,14 +12,20 @@ most common source of 401/403 confusion.
12
12
  | `/api/platform/*` (Studio: tenants CRUD) | **dashboard Basic Auth** (`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD`) | global service key → `401 Unauthorized` |
13
13
  | `/pg/query` (ad-hoc SQL) | **global service_role key**, sent as BOTH `Authorization: Bearer` and `apikey` | Basic Auth → 401 "apikey or Bearer required"; per-tenant key → `401` |
14
14
  | Data plane `/rest/v1`, `/auth/v1`; Management API `/v1/projects/default/*` (apps create/publish, functions deploy) | **per-tenant key** (Management API also needs `X-Tenant-Id`) | global key + `X-Tenant-Id` → `403 Global credentials cannot access tenant resources` |
15
+ | `/api/tenant-ddl/*` (tenant self-service DDL + RLS) | **per-tenant service_role key** — send only `Authorization: Bearer`; Kong verifies the JWT and injects the trusted `X-Tenant-Id` (never send it yourself) | global key → `403 GLOBAL_KEY_NOT_ALLOWED`; per-tenant anon → `403 ROLE_NOT_ALLOWED`; suspended tenant → `403 TENANT_NOT_ACTIVE` |
16
+
17
+ > RLS registry management is symmetric: `rls/tables` and `rls/policies` both support POST/GET/DELETE
18
+ > (DELETE on `rls/tables` does not require the table to still exist, so it cleans up after a DROP).
15
19
 
16
20
  - A per-tenant anon/service key carries a `tenant_id` claim, so **REST and Auth route from the
17
21
  key alone** — `X-Tenant-Id` is optional there. Issued user JWTs carry `aud=<tenant_id>`.
18
22
  - Using tenant X's key with `X-Tenant-Id: Y` → `403 Token tenant mismatch` (gateway guardrail).
19
- - CLI: `pxbaas login --endpoint <url> --tenant-id <id>` with the tenant's keys gives a working
20
- MT profile (`app capabilities`, `functions`, `rest` all fine), but **`pxbaas db query` returns
21
- 401 in MT** (the pg-meta path does not accept tenant keys). Run tenant SQL through
22
- `/pg/query` with the global key, using fully-qualified `tenant_<id>.<table>` names.
23
+ - CLI: `pxbaas login --endpoint <url> --tenant-id <id>` with the tenant's per-tenant keys gives a
24
+ working MT profile. As of the tenant self-service DDL capability, **`pxbaas db query`, `db push`,
25
+ and `app db-init` all work in MT** — they route through `/api/tenant-ddl` with the per-tenant
26
+ service_role key (DDL runs on the tenant's own db; the tenant MySQL account's `GRANT ALL ON
27
+ tenant_<id>.*` is the hard isolation boundary). The old "`db query` 401s in MT, use the global key
28
+ on `/pg/query`" guidance is obsolete; `/pg/query` remains platform-admin only.
23
29
 
24
30
  ## 2. Provisioning a tenant
25
31
 
@@ -63,6 +69,31 @@ curl -sS -o /dev/null -w '%{http_code}\n' -X POST "$ENDPOINT/rest/v1/posts" \
63
69
  -d '{"title":"x","author_id":"someone-else","status":"draft"}' # expect 403 PGRST302
64
70
  ```
65
71
 
72
+ ## 3b. Tenant self-service DDL / db-init as a normal tenant
73
+
74
+ A normal tenant (holding only its per-tenant service_role key — no platform admin credential) can
75
+ initialize and tear down its own app database through the CLI:
76
+
77
+ ```bash
78
+ pxbaas login --endpoint <url> --tenant-id <id> # paste the tenant's per-tenant anon + service keys
79
+ pxbaas app db-init <name> --dir ./apps --dry-run # preview the plan (DDL + seed + RLS)
80
+ pxbaas app db-init <name> --dir ./apps --yes # apply (stop-on-first-error, no rollback)
81
+ pxbaas db query "SELECT count(*) FROM posts" --json
82
+ pxbaas app destroy <name> --confirm --purge-db # optional: also drop the app's tables + RLS rows
83
+ ```
84
+
85
+ - `db-init`/`db push` read `db/schema.sql` (DDL + seed) and the declarative `db/rls.json` (RLS).
86
+ In MT the `__SCHEMA__` qualifier is dropped (the connection is already bound to the tenant db) and
87
+ cross-schema names are refused by GRANT.
88
+ - **RLS is registered through the structured `/api/tenant-ddl/rls/*` endpoints**, not raw central
89
+ INSERTs (the tenant account has no GRANT on the shared `supabase` db). `table_schema` is bound
90
+ server-side to the verified tenant db — the CLI never sends it, so a tenant cannot touch another's
91
+ policies. If `db/rls.json` is absent, RLS INSERTs embedded in `schema.sql` are parsed and routed
92
+ the same way.
93
+ - `--purge-db` DROPs the app's tables and DELETEs its RLS registry rows — both the policies and the
94
+ central `_mysqlrest_rls_tables` toggle (via `DELETE /api/tenant-ddl/rls/tables`). On instances that
95
+ predate that endpoint the toggle row is left (harmless; re-created on the next `db-init`).
96
+
66
97
  ## 4. Deploying a webapp into a tenant
67
98
 
68
99
  - Every Management API call needs `Authorization: Bearer <tenant service_role_key>` **and**
@@ -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
+ }
@@ -6,11 +6,13 @@
6
6
  -- · 多租户实例:__SCHEMA__ = tenant_<tenant_id>
7
7
  -- sed 's/__SCHEMA__/public/g' db/schema.sql
8
8
  --
9
- -- 执行通道:单租户可 pxbaas db query;多租户走 POST {endpoint}/pg/query
10
- -- (全局 service_role key 作 Authorization+apikey),语句写全限定名。
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 一并应用。
11
13
  --
12
- -- ⚠️ RLS 元数据落点(写错静默失效=RLS 关闭):永远写中心库 supabase._mysqlrest_rls_*,
13
- -- 且 table_schema 填 __SCHEMA__;租户库内同名表无人读取,勿写入。
14
+ -- ⚠️ RLS 元数据落点(写错静默失效=RLS 关闭):永远写中心库 supabase._mysqlrest_rls_*;
15
+ -- db-init 自动绑定 table_schema(单租户=--schema,多租户=服务端强绑),勿手写库名。
14
16
  -- =============================================================================
15
17
 
16
18
  CREATE TABLE IF NOT EXISTS `__SCHEMA__`.`posts` (
@@ -24,30 +26,8 @@ CREATE TABLE IF NOT EXISTS `__SCHEMA__`.`posts` (
24
26
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
25
27
  );
26
28
 
27
- -- ---- RLS 注册 ----
28
- INSERT IGNORE INTO supabase._mysqlrest_rls_tables (table_schema, table_name, rls_enabled, rls_forced)
29
- VALUES ('__SCHEMA__','posts',1,0);
30
-
31
- -- ---- RLS 策略:anon 只读 published;authenticated 读 published 或本人;写改删限本人 ----
32
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
33
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
34
- VALUES ('__SCHEMA__','posts','posts_select_anon','anon','SELECT','`status` = ''published''',NULL,1);
35
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
36
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
37
- VALUES ('__SCHEMA__','posts','posts_select_auth','authenticated','SELECT',
38
- '`status` = ''published'' OR author_id = {{request.jwt.claim.sub}}',NULL,1);
39
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
40
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
41
- VALUES ('__SCHEMA__','posts','posts_insert_auth','authenticated','INSERT',NULL,
42
- 'author_id = {{request.jwt.claim.sub}}',1);
43
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
44
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
45
- VALUES ('__SCHEMA__','posts','posts_update_auth','authenticated','UPDATE',
46
- 'author_id = {{request.jwt.claim.sub}}','author_id = {{request.jwt.claim.sub}}',1);
47
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
48
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
49
- VALUES ('__SCHEMA__','posts','posts_delete_auth','authenticated','DELETE',
50
- 'author_id = {{request.jwt.claim.sub}}',NULL,1);
29
+ -- ---- RLS 注册与策略:声明在同目录 db/rls.json,由 `pxbaas app db-init` 应用 ----
30
+ -- anon 只读 published;authenticated 读 published 或本人;写/改/删限本人(author_id = jwt.sub)。
51
31
 
52
32
  -- ---- 种子文章(3 篇 published,幂等)----
53
33
  INSERT INTO `__SCHEMA__`.`posts` (title, content, author_id, author_name, status)
@@ -163,6 +163,7 @@
163
163
  "README.md",
164
164
  "backend/index.ts",
165
165
  "db/schema.sql",
166
+ "db/rls.json",
166
167
  "frontend/dist/index.html"
167
168
  ],
168
169
  "frontend": {
@@ -182,7 +183,7 @@
182
183
  },
183
184
  "ssrProbePath": "/posts",
184
185
  "nextSteps": [
185
- "Initialize the database BEFORE deploy: run `pxbaas app db-init <name> --dir <appsDir>` (single-tenant). It reads db/schema.sql, substitutes __SCHEMA__=public, shows the full DDL + RLS + seed plan, and applies it only after you authorize (--dry-run to preview, --yes for non-interactive). The CLI never auto-runs DDL. Multi-tenant: use POST /pg/query with the global service_role key and tenant_<id>.<table> names.",
186
+ "Initialize the database BEFORE deploy: run `pxbaas app db-init <name> --dir <appsDir>`. It reads db/schema.sql (DDL + seed) and db/rls.json (RLS registry), shows the full plan, and applies it only after you authorize (--dry-run to preview, --yes for non-interactive). Works in single-tenant (/pg/query) AND multi-tenant (/api/tenant-ddl with your per-tenant service_role key); the CLI never auto-runs DDL.",
186
187
  "Requires capabilities.sessionMode=proxy-injected-header.",
187
188
  "Data-plane base and anon key come from the platform-in SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY (edge-runtime MR #30497903); no plaintext key editing is needed."
188
189
  ]
@@ -196,6 +197,7 @@
196
197
  "README.md",
197
198
  "backend/index.ts",
198
199
  "db/schema.sql",
200
+ "db/rls.json",
199
201
  "frontend/dist/admin.html",
200
202
  "frontend/dist/article.html",
201
203
  "frontend/dist/blog.js",
@@ -222,7 +224,7 @@
222
224
  "session": "proxy-injected"
223
225
  },
224
226
  "nextSteps": [
225
- "Initialize the database BEFORE deploy: run `pxbaas app db-init <name> --dir <appsDir>` (single-tenant). It reads db/schema.sql, substitutes __SCHEMA__=public, shows the full DDL + RLS + seed plan, and applies it only after you authorize (--dry-run to preview, --yes for non-interactive). The CLI never auto-runs DDL. Multi-tenant: use POST /pg/query with the global service_role key and tenant_<id>.<table> names.",
227
+ "Initialize the database BEFORE deploy: run `pxbaas app db-init <name> --dir <appsDir>`. It reads db/schema.sql (DDL + seed) and db/rls.json (RLS registry), shows the full plan, and applies it only after you authorize (--dry-run to preview, --yes for non-interactive). Works in single-tenant (/pg/query) AND multi-tenant (/api/tenant-ddl with your per-tenant service_role key); the CLI never auto-runs DDL.",
226
228
  "Requires capabilities.sessionMode=proxy-injected-header.",
227
229
  "The BFF forwards to the platform-injected SUPABASE_INTERNAL_URL with SUPABASE_ANON_KEY (edge-runtime MR #30497903); no plaintext key editing is needed."
228
230
  ]
@@ -236,6 +238,7 @@
236
238
  "README.md",
237
239
  "backend/index.ts",
238
240
  "db/schema.sql",
241
+ "db/rls.json",
239
242
  "frontend/dist/index.html"
240
243
  ],
241
244
  "frontend": {
@@ -255,7 +258,7 @@
255
258
  },
256
259
  "ssrProbePath": "/shop",
257
260
  "nextSteps": [
258
- "Initialize the database BEFORE deploy: run `pxbaas app db-init <name> --dir <appsDir>` (single-tenant). It reads db/schema.sql, substitutes __SCHEMA__=public, shows the full DDL + RLS (products public-read; orders and order_items are owner-gated by buyer_email = jwt.email) + seed plan, and applies it only after you authorize (--dry-run to preview, --yes for non-interactive). The CLI never auto-runs DDL. Multi-tenant: use POST /pg/query with the global service_role key and tenant_<id>.<table> names.",
261
+ "Initialize the database BEFORE deploy: run `pxbaas app db-init <name> --dir <appsDir>`. It reads db/schema.sql (DDL + seed) and db/rls.json (RLS: products public-read; orders and order_items owner-gated by buyer_email = jwt.email), shows the full plan, and applies it only after you authorize (--dry-run to preview, --yes for non-interactive). Works in single-tenant (/pg/query) AND multi-tenant (/api/tenant-ddl with your per-tenant service_role key); the CLI never auto-runs DDL.",
259
262
  "Create the admin account (admin@shop.demo) via GoTrue signup if the admin whitelist is needed.",
260
263
  "Data-plane base and anon key come from the platform-injected SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY (edge-runtime MR #30497903); no plaintext key editing is needed. Requires the instance to report react/react-dom in capabilities.ssrLibs."
261
264
  ]
@@ -10,10 +10,27 @@ app.use("*", async (context, next) => {
10
10
  });
11
11
 
12
12
  app.options("*", (context) => context.body(null, 204));
13
- app.get("/health", (context) => context.json({ ok: true, app: "{{SLUG}}" }));
14
- app.get("/hello", (context) =>
15
- context.json({ message: "Hello from {{APP_NAME}}" }),
16
- );
13
+
14
+ // Path contract (APP_WORKER_PATH_REWRITE off = current default): the proxy
15
+ // forwards /apps/{mount}/api/<x> to the worker as /<candidateSlug>/<x>, so a
16
+ // literal app.get("/health") never matches at runtime. Normalize the candidate
17
+ // slug prefix away and dispatch on the logical path (same convention as the
18
+ // ssr-shop / ssr-blog / csr-bff templates).
19
+ function norm(path: string): string {
20
+ return path.replace(/^\/app_[^/]+/, "") || path;
21
+ }
22
+
23
+ app.get("*", (context) => {
24
+ const p = norm(new URL(context.req.url).pathname);
25
+ if (p === "/health" || p === "/api/health") {
26
+ return context.json({ ok: true, app: "{{SLUG}}" });
27
+ }
28
+ if (p === "/hello" || p === "/api/hello") {
29
+ return context.json({ message: "Hello from {{APP_NAME}}" });
30
+ }
31
+ return context.json({ code: "NOT_FOUND", message: "Route not found", path: p }, 404);
32
+ });
33
+
17
34
  app.notFound((context) =>
18
35
  context.json({ code: "NOT_FOUND", message: "Route not found" }, 404),
19
36
  );
@@ -0,0 +1,13 @@
1
+ {
2
+ "$comment": "Declarative RLS for the SSR 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_ssr", "rlsEnabled": true, "rlsForced": false }
5
+ ],
6
+ "policies": [
7
+ { "table": "posts_ssr", "name": "posts_ssr_select_anon", "role": "anon", "command": "SELECT", "using": "`status` = 'published'", "check": null, "permissive": true },
8
+ { "table": "posts_ssr", "name": "posts_ssr_select_auth", "role": "authenticated", "command": "SELECT", "using": "`status` = 'published' OR author_id = {{request.jwt.claim.sub}}", "check": null, "permissive": true },
9
+ { "table": "posts_ssr", "name": "posts_ssr_insert_auth", "role": "authenticated", "command": "INSERT", "using": null, "check": "author_id = {{request.jwt.claim.sub}}", "permissive": true },
10
+ { "table": "posts_ssr", "name": "posts_ssr_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_ssr", "name": "posts_ssr_delete_auth", "role": "authenticated", "command": "DELETE", "using": "author_id = {{request.jwt.claim.sub}}", "check": null, "permissive": true }
12
+ ]
13
+ }
@@ -6,11 +6,13 @@
6
6
  -- · 多租户实例:__SCHEMA__ = tenant_<tenant_id>
7
7
  -- sed 's/__SCHEMA__/public/g' db/schema.sql
8
8
  --
9
- -- 执行通道:单租户可 pxbaas db query;多租户走 POST {endpoint}/pg/query
10
- -- (全局 service_role key 作 Authorization+apikey),语句写全限定名。
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 一并应用。
11
13
  --
12
- -- ⚠️ RLS 元数据落点(写错静默失效=RLS 关闭):永远写中心库 supabase._mysqlrest_rls_*,
13
- -- 且 table_schema 填 __SCHEMA__;租户库内同名表无人读取,勿写入。
14
+ -- ⚠️ RLS 元数据落点(写错静默失效=RLS 关闭):永远写中心库 supabase._mysqlrest_rls_*;
15
+ -- db-init 自动绑定 table_schema(单租户=--schema,多租户=服务端强绑),勿手写库名。
14
16
  -- =============================================================================
15
17
 
16
18
  CREATE TABLE IF NOT EXISTS `__SCHEMA__`.`posts_ssr` (
@@ -26,30 +28,8 @@ CREATE TABLE IF NOT EXISTS `__SCHEMA__`.`posts_ssr` (
26
28
  KEY `idx_posts_ssr_status` (`status`, `created_at`)
27
29
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
28
30
 
29
- -- ---- RLS 注册 ----
30
- INSERT IGNORE INTO supabase._mysqlrest_rls_tables (table_schema, table_name, rls_enabled, rls_forced)
31
- VALUES ('__SCHEMA__','posts_ssr',1,0);
32
-
33
- -- ---- RLS 策略:anon 只读 published;authenticated 读 published 或本人;写改删限本人 ----
34
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
35
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
36
- VALUES ('__SCHEMA__','posts_ssr','posts_ssr_select_anon','anon','SELECT','`status` = ''published''',NULL,1);
37
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
38
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
39
- VALUES ('__SCHEMA__','posts_ssr','posts_ssr_select_auth','authenticated','SELECT',
40
- '`status` = ''published'' OR author_id = {{request.jwt.claim.sub}}',NULL,1);
41
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
42
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
43
- VALUES ('__SCHEMA__','posts_ssr','posts_ssr_insert_auth','authenticated','INSERT',NULL,
44
- 'author_id = {{request.jwt.claim.sub}}',1);
45
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
46
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
47
- VALUES ('__SCHEMA__','posts_ssr','posts_ssr_update_auth','authenticated','UPDATE',
48
- 'author_id = {{request.jwt.claim.sub}}','author_id = {{request.jwt.claim.sub}}',1);
49
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
50
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
51
- VALUES ('__SCHEMA__','posts_ssr','posts_ssr_delete_auth','authenticated','DELETE',
52
- 'author_id = {{request.jwt.claim.sub}}',NULL,1);
31
+ -- ---- RLS 注册与策略:声明在同目录 db/rls.json,由 `pxbaas app db-init` 应用 ----
32
+ -- anon 只读 published;authenticated 读 published 或本人;写/改/删限本人(author_id = jwt.sub)。
53
33
 
54
34
  -- ---- 种子文章(3 篇 published,幂等)----
55
35
  INSERT INTO `__SCHEMA__`.`posts_ssr` (title, content, author_id, author_name, status)
@@ -37,30 +37,45 @@ function esc(s: string): string {
37
37
  .split(String.fromCharCode(34)).join("&quot;");
38
38
  }
39
39
 
40
- // L3 shape demo: server-side fetch of DB content via the derived internal base
41
- // (anon key from env; RLS enforced server-side). Degrades gracefully so the
42
- // shell still renders if the internal entry is ever unreachable.
43
- app.get("/ssr-data", async (c) => {
44
- let rows: Array<Record<string, unknown>> = [];
45
- let note = "";
46
- try {
47
- const r = await fetch(internalBase() + "/rest/v1/?select=1", {
48
- headers: { apikey: Deno.env.get("SUPABASE_ANON_KEY") || "" },
49
- });
50
- note = `internal base reachable: http-${r.status}`;
51
- } catch (e) {
52
- note = `internal base unreachable: ${String(e).slice(0, 60)}`;
53
- }
54
- return c.html(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>L3 demo</title></head><body>
40
+ // Path contract (APP_WORKER_PATH_REWRITE off = current default): the proxy
41
+ // forwards /apps/{mount}/api/<x> to the worker as /<candidateSlug>/<x> — the
42
+ // candidate slug prefix is NOT stripped at runtime (the stripped path is only
43
+ // delivered via X-Pxbaas-Worker-Path when the rewrite kill-switch is ON, which
44
+ // is disabled by default due to the D18 streamRid regression). Normalize the
45
+ // slug prefix away and match on the logical path, so handlers resolve under ANY
46
+ // prefix shape (/{slug}/x, /api/x, or /x) and still work if the switch flips on.
47
+ // Same convention as the ssr-shop / ssr-blog / csr-bff templates.
48
+ function norm(path: string): string {
49
+ return path.replace(/^\/app_[^/]+/, "") || path;
50
+ }
51
+
52
+ app.get("*", async (c) => {
53
+ const p = norm(new URL(c.req.url).pathname);
54
+
55
+ // L3 shape demo: server-side fetch of DB content via the derived internal base
56
+ // (anon key from env; RLS enforced server-side). Degrades gracefully so the
57
+ // shell still renders if the internal entry is ever unreachable.
58
+ if (p === "/ssr-data") {
59
+ let rows: Array<Record<string, unknown>> = [];
60
+ let note = "";
61
+ try {
62
+ const r = await fetch(internalBase() + "/rest/v1/?select=1", {
63
+ headers: { apikey: Deno.env.get("SUPABASE_ANON_KEY") || "" },
64
+ });
65
+ note = `internal base reachable: http-${r.status}`;
66
+ } catch (e) {
67
+ note = `internal base unreachable: ${String(e).slice(0, 60)}`;
68
+ }
69
+ return c.html(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>L3 demo</title></head><body>
55
70
  <div id="__ssr" data-ssr="v1"><h1>L3 full-SSR demo</h1><p>${esc(note)}</p><pre>${esc(JSON.stringify(rows))}</pre></div>
56
71
  </body></html>`);
57
- });
72
+ }
58
73
 
59
- app.get("/page", (c) => {
60
- const theme = c.req.query("theme") === "dark" ? "dark" : "light";
61
- const bg = theme === "dark" ? "#111" : "#fafafa";
62
- const fg = theme === "dark" ? "#eee" : "#222";
63
- const html = `<!DOCTYPE html><html><head><meta charset="utf-8">
74
+ if (p === "/page") {
75
+ const theme = c.req.query("theme") === "dark" ? "dark" : "light";
76
+ const bg = theme === "dark" ? "#111" : "#fafafa";
77
+ const fg = theme === "dark" ? "#eee" : "#222";
78
+ const html = `<!DOCTYPE html><html><head><meta charset="utf-8">
64
79
  <title>{{APP_NAME}} SSR</title>
65
80
  <style>body{font-family:sans-serif;max-width:720px;margin:40px auto;background:${bg};color:${fg}}</style>
66
81
  </head><body>
@@ -77,16 +92,20 @@ app.get("/page", (c) => {
77
92
  })();
78
93
  </script>
79
94
  </body></html>`;
80
- return c.html(html);
81
- });
95
+ return c.html(html);
96
+ }
97
+
98
+ // JSON API. /apps/{m}/api/data and /api/health normalize to /data and /health
99
+ // after the slug strip; the /api-prefixed shapes are kept as a defensive
100
+ // fallback for deployments that deliver the logical path un-stripped.
101
+ if (p === "/data" || p === "/api/data") {
102
+ return c.json({ ok: true, engine: "hono", now: new Date().toISOString() });
103
+ }
104
+ if (p === "/health" || p === "/api/health") {
105
+ return c.json({ ok: true });
106
+ }
82
107
 
83
- // Path contract (APP_WORKER_PATH_REWRITE off, current default): the /api/* branch
84
- // delivers the STRIPPED logical path (/data, /health) while static-miss
85
- // fallthrough delivers the FULL logical path (/page). Register both shapes so
86
- // the worker behaves identically when the rewrite kill-switch is flipped back on.
87
- app.get("/data", (c) => c.json({ ok: true, engine: "hono", now: new Date().toISOString() }));
88
- app.get("/api/data", (c) => c.json({ ok: true, engine: "hono", now: new Date().toISOString() }));
89
- app.get("/health", (c) => c.json({ ok: true }));
90
- app.get("/api/health", (c) => c.json({ ok: true }));
108
+ return c.json({ ok: false, error: "not_found", path: p }, 404);
109
+ });
91
110
 
92
111
  Deno.serve(app.fetch);
@@ -0,0 +1,16 @@
1
+ {
2
+ "$comment": "Declarative RLS for the shop 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. using/check expressions may embed {{request.jwt.claim.*}} templates.",
3
+ "tables": [
4
+ { "table": "products", "rlsEnabled": true, "rlsForced": false },
5
+ { "table": "orders", "rlsEnabled": true, "rlsForced": false },
6
+ { "table": "order_items", "rlsEnabled": true, "rlsForced": false }
7
+ ],
8
+ "policies": [
9
+ { "table": "products", "name": "products_select_anon", "role": "anon", "command": "SELECT", "using": "1=1", "check": null, "permissive": true },
10
+ { "table": "products", "name": "products_select_auth", "role": "authenticated", "command": "SELECT", "using": "1=1", "check": null, "permissive": true },
11
+ { "table": "orders", "name": "orders_select_auth", "role": "authenticated", "command": "SELECT", "using": "`buyer_email` = {{request.jwt.claim.email}} OR {{request.jwt.claim.email}} = 'admin@shop.demo'", "check": null, "permissive": true },
12
+ { "table": "orders", "name": "orders_insert_auth", "role": "authenticated", "command": "INSERT", "using": null, "check": "`buyer_email` = {{request.jwt.claim.email}}", "permissive": true },
13
+ { "table": "order_items", "name": "order_items_select_auth", "role": "authenticated", "command": "SELECT", "using": "`buyer_email` = {{request.jwt.claim.email}} OR {{request.jwt.claim.email}} = 'admin@shop.demo'", "check": null, "permissive": true },
14
+ { "table": "order_items", "name": "order_items_insert_auth", "role": "authenticated", "command": "INSERT", "using": null, "check": "`buyer_email` = {{request.jwt.claim.email}}", "permissive": true }
15
+ ]
16
+ }
@@ -6,15 +6,16 @@
6
6
  -- · 多租户实例:__SCHEMA__ = tenant_<tenant_id>(每租户一套库)
7
7
  -- sed 's/__SCHEMA__/public/g' db/schema.sql
8
8
  --
9
- -- 执行通道:
10
- -- · 单租户:pxbaas db query "<单条 SQL>" --role service_role --profile <p>
11
- -- · 多租户:POST {endpoint}/pg/query(全局 service_role key 作 Authorization+apikey),
12
- -- 语句里必须写全限定名 tenant_<id>.<table>;pxbaas db query 在 MT 下会 401。
9
+ -- 执行通道(推荐):`pxbaas app db-init <name>` —— 单/多租户通用,自动:
10
+ -- · 单租户:__SCHEMA__ → public,经 /pg/query(全局 admin)执行;
11
+ -- · 多租户:__SCHEMA__ 去限定(连接已锁租户库),经 /api/tenant-ddl 用
12
+ -- per-tenant service_role key 执行;GRANT 仅本租户库,天然隔离。
13
+ -- 本文件只含 DDL + 种子;RLS 声明在同目录 db/rls.json,由 db-init 一并应用。
13
14
  --
14
15
  -- ⚠️ RLS 元数据的落点(写错静默失效,等价于关闭 RLS):
15
- -- 永远写中心库 supabase._mysqlrest_rls_tables / _mysqlrest_rls_policies,
16
- -- 且 table_schema 填 __SCHEMA__(单租户=public,多租户=tenant_<id>)。
17
- -- 租户库里由开通流程创建的同名 _mysqlrest_rls_* 表没有任何服务读取,勿写入。
16
+ -- 永远写中心库 supabase._mysqlrest_rls_tables / _mysqlrest_rls_policies。
17
+ -- db-init 会自动绑定 table_schema(单租户=--schema,多租户=服务端强绑租户库),
18
+ -- 无需也不要手写库名。租户库内同名 _mysqlrest_rls_* 表没有任何服务读取,勿写入。
18
19
  -- =============================================================================
19
20
 
20
21
  -- ---- 表结构 ----
@@ -54,41 +55,10 @@ CREATE TABLE IF NOT EXISTS `__SCHEMA__`.`order_items` (
54
55
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
55
56
  );
56
57
 
57
- -- ---- RLS 注册(中心库 supabase)----
58
- INSERT IGNORE INTO supabase._mysqlrest_rls_tables (table_schema, table_name, rls_enabled, rls_forced)
59
- VALUES ('__SCHEMA__','products',1,0), ('__SCHEMA__','orders',1,0), ('__SCHEMA__','order_items',1,0);
60
-
61
- -- ---- RLS 策略 ----
62
- -- products:公开目录,anon / authenticated 均可读
63
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
64
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
65
- VALUES ('__SCHEMA__','products','products_select_anon','anon','SELECT','1=1',NULL,1);
66
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
67
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
68
- VALUES ('__SCHEMA__','products','products_select_auth','authenticated','SELECT','1=1',NULL,1);
69
-
70
- -- orders:仅本人(buyer_email = jwt.email)或管理员白名单可读;仅本人可写
71
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
72
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
73
- VALUES ('__SCHEMA__','orders','orders_select_auth','authenticated','SELECT',
74
- '`buyer_email` = {{request.jwt.claim.email}} OR {{request.jwt.claim.email}} = ''admin@shop.demo''',
75
- NULL, 1);
76
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
77
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
78
- VALUES ('__SCHEMA__','orders','orders_insert_auth','authenticated','INSERT',NULL,
79
- '`buyer_email` = {{request.jwt.claim.email}}', 1);
80
-
81
- -- order_items:仅本人(buyer_email = jwt.email)或管理员白名单可读;仅本人可写(owner 门禁落在本表,
82
- -- 不依赖前端按 order_id 过滤 —— 前端过滤不是安全边界,R2-05)。
83
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
84
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
85
- VALUES ('__SCHEMA__','order_items','order_items_select_auth','authenticated','SELECT',
86
- '`buyer_email` = {{request.jwt.claim.email}} OR {{request.jwt.claim.email}} = ''admin@shop.demo''',
87
- NULL, 1);
88
- INSERT IGNORE INTO supabase._mysqlrest_rls_policies
89
- (table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
90
- VALUES ('__SCHEMA__','order_items','order_items_insert_auth','authenticated','INSERT',NULL,
91
- '`buyer_email` = {{request.jwt.claim.email}}', 1);
58
+ -- ---- RLS 注册与策略:声明在同目录 db/rls.json,由 `pxbaas app db-init` 应用 ----
59
+ -- · products:公开目录,anon / authenticated 均可读;
60
+ -- · orders / order_items:仅本人(buyer_email = jwt.email)或管理员白名单可读,仅本人可写。
61
+ -- (不再内联 INSERT,避免与 rls.json 双源;如需手动注册见 db/rls.json。)
92
62
 
93
63
  -- ---- 种子商品(10 件,幂等)----
94
64
  INSERT INTO `__SCHEMA__`.`products` (name, description, price_cents, stock, emoji, category)