@nextsparkjs/ai-workflow 0.1.0-beta.166 → 0.1.0-beta.167

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 (41) hide show
  1. package/claude/skills/README.md +72 -47
  2. package/claude/skills/anthropics-mcp-builder/LICENSE.txt +202 -0
  3. package/claude/skills/anthropics-mcp-builder/SKILL.md +236 -0
  4. package/claude/skills/anthropics-mcp-builder/reference/evaluation.md +602 -0
  5. package/claude/skills/anthropics-mcp-builder/reference/mcp_best_practices.md +249 -0
  6. package/claude/skills/anthropics-mcp-builder/reference/node_mcp_server.md +970 -0
  7. package/claude/skills/anthropics-mcp-builder/reference/python_mcp_server.md +719 -0
  8. package/claude/skills/anthropics-mcp-builder/scripts/connections.py +151 -0
  9. package/claude/skills/anthropics-mcp-builder/scripts/evaluation.py +373 -0
  10. package/claude/skills/anthropics-mcp-builder/scripts/example_evaluation.xml +22 -0
  11. package/claude/skills/anthropics-mcp-builder/scripts/requirements.txt +2 -0
  12. package/claude/skills/anthropics-skill-creator/LICENSE.txt +202 -0
  13. package/claude/skills/anthropics-skill-creator/SKILL.md +485 -0
  14. package/claude/skills/anthropics-skill-creator/agents/analyzer.md +274 -0
  15. package/claude/skills/anthropics-skill-creator/agents/comparator.md +202 -0
  16. package/claude/skills/anthropics-skill-creator/agents/grader.md +223 -0
  17. package/claude/skills/anthropics-skill-creator/assets/eval_review.html +146 -0
  18. package/claude/skills/anthropics-skill-creator/eval-viewer/generate_review.py +471 -0
  19. package/claude/skills/anthropics-skill-creator/eval-viewer/viewer.html +1325 -0
  20. package/claude/skills/anthropics-skill-creator/references/schemas.md +430 -0
  21. package/claude/skills/anthropics-skill-creator/scripts/__init__.py +0 -0
  22. package/claude/skills/anthropics-skill-creator/scripts/aggregate_benchmark.py +401 -0
  23. package/claude/skills/anthropics-skill-creator/scripts/generate_report.py +326 -0
  24. package/claude/skills/anthropics-skill-creator/scripts/improve_description.py +247 -0
  25. package/claude/skills/anthropics-skill-creator/scripts/package_skill.py +136 -0
  26. package/claude/skills/anthropics-skill-creator/scripts/quick_validate.py +103 -0
  27. package/claude/skills/anthropics-skill-creator/scripts/run_eval.py +310 -0
  28. package/claude/skills/anthropics-skill-creator/scripts/run_loop.py +328 -0
  29. package/claude/skills/anthropics-skill-creator/scripts/utils.py +47 -0
  30. package/claude/skills/api-bypass-layers/SKILL.md +20 -7
  31. package/claude/skills/database-migrations/SKILL.md +23 -8
  32. package/claude/skills/entity-system/SKILL.md +2 -2
  33. package/claude/skills/entity-system/scripts/generate-migration.py +64 -54
  34. package/claude/skills/nextjs-api-development/SKILL.md +6 -1
  35. package/claude/skills/permissions-system/SKILL.md +11 -2
  36. package/claude/skills/rls-enforcement/SKILL.md +192 -0
  37. package/claude/skills/rls-enforcement/evals/RESULTS.md +24 -0
  38. package/claude/skills/rls-enforcement/evals/evals.json +56 -0
  39. package/claude/skills/service-layer/SKILL.md +13 -2
  40. package/package.json +1 -1
  41. package/LICENSE +0 -21
@@ -56,10 +56,11 @@ FIELD_TYPE_MAP = {
56
56
  'select': 'VARCHAR(100)',
57
57
  'multiselect': 'TEXT[]',
58
58
  'tags': 'TEXT[]',
59
- 'relation': 'UUID',
60
- 'relation-multi': 'UUID[]',
61
- 'reference': 'UUID',
62
- 'user': 'UUID',
59
+ # FKs are TEXT: NextSpark ids are TEXT (Better Auth + gen_random_uuid()::text), not UUID
60
+ 'relation': 'TEXT',
61
+ 'relation-multi': 'TEXT[]',
62
+ 'reference': 'TEXT',
63
+ 'user': 'TEXT',
63
64
  'phone': 'VARCHAR(50)',
64
65
  'rating': 'INTEGER',
65
66
  'range': 'DECIMAL',
@@ -124,16 +125,19 @@ def parse_fields_file(fields_path: Path) -> List[Dict]:
124
125
 
125
126
  def generate_column_sql(field: Dict) -> str:
126
127
  """Generate SQL column definition from field."""
127
- name = to_snake_case(field['name'])
128
+ # NextSpark column names are camelCase (e.g. "firstName"), even though table
129
+ # names are snake_case (e.g. "team_members"). Field names from config may be
130
+ # camelCase already (no-op) or kebab-case (converted).
131
+ name = to_camel_case(field['name'])
128
132
  field_type = field['type']
129
133
  required = field['required']
130
134
 
131
135
  sql_type = FIELD_TYPE_MAP.get(field_type, 'TEXT')
132
136
 
133
- # Handle select fields with CHECK constraint
137
+ # Handle select fields with CHECK constraint (quote the camelCase column)
134
138
  if field_type == 'select' and 'options' in field:
135
139
  options = ', '.join(f"'{v}'" for v in field['options'])
136
- sql_type = f"VARCHAR(100) CHECK ({name} IN ({options}))"
140
+ sql_type = f'VARCHAR(100) CHECK ("{name}" IN ({options}))'
137
141
 
138
142
  nullable = '' if required else ''
139
143
  not_null = ' NOT NULL' if required else ''
@@ -146,10 +150,10 @@ def generate_table_migration(entity_slug: str, fields: List[Dict], options: Dict
146
150
  table_name = to_snake_case(entity_slug)
147
151
  timestamp = datetime.now().isoformat()
148
152
 
149
- # Build column definitions
153
+ # Build column definitions (NextSpark: TEXT ids, camelCase columns, "users" table)
150
154
  columns = [
151
- ' "id" UUID PRIMARY KEY DEFAULT gen_random_uuid()',
152
- ' "user_id" UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE',
155
+ ' "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text',
156
+ ' "userId" TEXT NOT NULL REFERENCES "users"(id) ON DELETE CASCADE',
153
157
  ]
154
158
 
155
159
  for field in fields:
@@ -157,15 +161,15 @@ def generate_table_migration(entity_slug: str, fields: List[Dict], options: Dict
157
161
 
158
162
  # Timestamps
159
163
  columns.extend([
160
- ' "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP',
161
- ' "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP',
164
+ ' "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()',
165
+ ' "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()',
162
166
  ])
163
167
 
164
168
  # Soft delete
165
169
  if options.get('soft_delete'):
166
170
  columns.extend([
167
- ' "deleted_at" TIMESTAMPTZ',
168
- ' "deleted_by" UUID REFERENCES "user"(id)',
171
+ ' "deletedAt" TIMESTAMPTZ',
172
+ ' "deletedBy" TEXT REFERENCES "users"(id)',
169
173
  ])
170
174
 
171
175
  # Build SQL
@@ -186,19 +190,19 @@ CREATE TABLE "{table_name}" (
186
190
  if options.get('with_indexes', True):
187
191
  sql += f'''
188
192
  -- Indexes
189
- CREATE INDEX "idx_{table_name}_user_id" ON "{table_name}" ("user_id");
190
- CREATE INDEX "idx_{table_name}_created_at" ON "{table_name}" ("created_at" DESC);
193
+ CREATE INDEX "idx_{table_name}_userId" ON "{table_name}" ("userId");
194
+ CREATE INDEX "idx_{table_name}_createdAt" ON "{table_name}" ("createdAt" DESC);
191
195
  '''
192
- # Add indexes for searchable/sortable fields
196
+ # Add indexes for searchable/sortable fields (camelCase column names)
193
197
  for field in fields:
194
- name = to_snake_case(field['name'])
198
+ name = to_camel_case(field['name'])
195
199
  if field['type'] in ['text', 'textarea']:
196
- sql += f'CREATE INDEX "idx_{table_name}_{name}_search" ON "{table_name}" USING gin(to_tsvector(\'english\', {name}));\n'
200
+ sql += f'CREATE INDEX "idx_{table_name}_{name}_search" ON "{table_name}" USING gin(to_tsvector(\'english\', "{name}"));\n'
197
201
  elif field['type'] == 'select':
198
202
  sql += f'CREATE INDEX "idx_{table_name}_{name}" ON "{table_name}" ("{name}");\n'
199
203
 
200
204
  if options.get('soft_delete'):
201
- sql += f'CREATE INDEX "idx_{table_name}_deleted_at" ON "{table_name}" ("deleted_at");\n'
205
+ sql += f'CREATE INDEX "idx_{table_name}_deletedAt" ON "{table_name}" ("deletedAt");\n'
202
206
 
203
207
  # RLS
204
208
  if options.get('with_rls', True):
@@ -206,22 +210,21 @@ CREATE INDEX "idx_{table_name}_created_at" ON "{table_name}" ("created_at" DESC)
206
210
  -- Enable Row Level Security
207
211
  ALTER TABLE "{table_name}" ENABLE ROW LEVEL SECURITY;
208
212
 
209
- -- RLS Policies
213
+ -- RLS Policies (NextSpark model: app.user_id GUC via public.get_auth_user_id();
214
+ -- bypass tier via public.can_bypass_rls(). System ops run on the SERVICE connection
215
+ -- which bypasses RLS by credential — no `TO service_role` policy is needed.)
210
216
  CREATE POLICY "{table_name}_owner_access" ON "{table_name}"
211
- FOR ALL
212
- USING (auth.uid() = user_id);
213
-
214
- CREATE POLICY "{table_name}_service_role_access" ON "{table_name}"
215
- FOR ALL TO service_role
216
- USING (TRUE);
217
+ FOR ALL TO authenticated
218
+ USING (public.can_bypass_rls() OR "userId" = public.get_auth_user_id())
219
+ WITH CHECK (public.can_bypass_rls() OR "userId" = public.get_auth_user_id());
217
220
  '''
218
221
 
219
222
  sql += f'''
220
- -- Trigger for updated_at
221
- CREATE TRIGGER update_{table_name}_updated_at
223
+ -- Trigger for updatedAt (uses the core helper public.set_updated_at from migration 001)
224
+ CREATE TRIGGER {table_name}_set_updated_at
222
225
  BEFORE UPDATE ON "{table_name}"
223
226
  FOR EACH ROW
224
- EXECUTE FUNCTION update_updated_at_column();
227
+ EXECUTE FUNCTION public.set_updated_at();
225
228
 
226
229
  COMMIT;
227
230
 
@@ -246,45 +249,52 @@ def generate_metas_migration(entity_slug: str) -> str:
246
249
  -- Up Migration
247
250
  BEGIN;
248
251
 
249
- -- Create {table_name}_metas table
252
+ -- Create {table_name}_metas table (NextSpark meta convention: TEXT ids, camelCase,
253
+ -- entityId / metaKey / metaValue / isPublic)
250
254
  CREATE TABLE "{table_name}_metas" (
251
- "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
252
- "entity_id" UUID NOT NULL REFERENCES "{table_name}"(id) ON DELETE CASCADE,
253
- "key" VARCHAR(255) NOT NULL,
254
- "value" JSONB,
255
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
256
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
257
- UNIQUE("entity_id", "key")
255
+ "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
256
+ "entityId" TEXT NOT NULL REFERENCES "{table_name}"(id) ON DELETE CASCADE,
257
+ "metaKey" VARCHAR(255) NOT NULL,
258
+ "metaValue" JSONB,
259
+ "isPublic" BOOLEAN NOT NULL DEFAULT false,
260
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
261
+ "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
262
+ UNIQUE("entityId", "metaKey")
258
263
  );
259
264
 
260
265
  -- Indexes
261
- CREATE INDEX "idx_{table_name}_metas_entity_id" ON "{table_name}_metas" ("entity_id");
262
- CREATE INDEX "idx_{table_name}_metas_key" ON "{table_name}_metas" ("key");
263
- CREATE INDEX "idx_{table_name}_metas_value" ON "{table_name}_metas" USING gin("value");
266
+ CREATE INDEX "idx_{table_name}_metas_entityId" ON "{table_name}_metas" ("entityId");
267
+ CREATE INDEX "idx_{table_name}_metas_metaKey" ON "{table_name}_metas" ("metaKey");
268
+ CREATE INDEX "idx_{table_name}_metas_metaValue" ON "{table_name}_metas" USING gin("metaValue");
264
269
 
265
270
  -- Enable Row Level Security
266
271
  ALTER TABLE "{table_name}_metas" ENABLE ROW LEVEL SECURITY;
267
272
 
268
- -- RLS Policies
273
+ -- RLS Policies (access inherited from the parent row's ownership; public metas readable)
269
274
  CREATE POLICY "{table_name}_metas_owner_access" ON "{table_name}_metas"
270
- FOR ALL
275
+ FOR ALL TO authenticated
271
276
  USING (
272
- EXISTS (
273
- SELECT 1 FROM "{table_name}"
274
- WHERE "{table_name}".id = entity_id
275
- AND "{table_name}".user_id = auth.uid()
277
+ public.can_bypass_rls()
278
+ OR EXISTS (
279
+ SELECT 1 FROM "{table_name}" p
280
+ WHERE p.id = "{table_name}_metas"."entityId"
281
+ AND p."userId" = public.get_auth_user_id()
282
+ )
283
+ )
284
+ WITH CHECK (
285
+ public.can_bypass_rls()
286
+ OR EXISTS (
287
+ SELECT 1 FROM "{table_name}" p
288
+ WHERE p.id = "{table_name}_metas"."entityId"
289
+ AND p."userId" = public.get_auth_user_id()
276
290
  )
277
291
  );
278
292
 
279
- CREATE POLICY "{table_name}_metas_service_role_access" ON "{table_name}_metas"
280
- FOR ALL TO service_role
281
- USING (TRUE);
282
-
283
- -- Trigger for updated_at
284
- CREATE TRIGGER update_{table_name}_metas_updated_at
293
+ -- Trigger for updatedAt (uses the core helper public.set_updated_at from migration 001)
294
+ CREATE TRIGGER {table_name}_metas_set_updated_at
285
295
  BEFORE UPDATE ON "{table_name}_metas"
286
296
  FOR EACH ROW
287
- EXECUTE FUNCTION update_updated_at_column();
297
+ EXECUTE FUNCTION public.set_updated_at();
288
298
 
289
299
  COMMIT;
290
300
 
@@ -5,7 +5,7 @@ description: |
5
5
  Dual authentication (API Key + Session), dynamic entities, metadata system.
6
6
  Use this skill to create endpoints, validate APIs, or understand API patterns.
7
7
  allowed-tools: Read, Glob, Grep, Bash(python:*)
8
- version: 1.1.0
8
+ version: 1.2.0
9
9
  ---
10
10
 
11
11
  # Next.js API Development Skill
@@ -311,6 +311,11 @@ describe('Products API - CRUD Operations', {
311
311
 
312
312
  ### SQL Injection Prevention
313
313
 
314
+ > **RLS routing (beta.167):** `queryWithRLS(..., userId)` runs on the app pool (RLS evaluated).
315
+ > The bare `query()`/`queryOne()` family routes to the **service pool (bypass)** — never use it
316
+ > for a user-scoped read. See the **rls-enforcement** skill for the service-pool model and the
317
+ > LIST/READ fail-closed enforcement now applied in the generic handler.
318
+
314
319
  ```typescript
315
320
  // ✅ CORRECT - Parameterized queries with queryWithRLS
316
321
  const result = await queryWithRLS(
@@ -2,10 +2,12 @@
2
2
  name: permissions-system
3
3
  description: |
4
4
  Three-layer permission system (Team Roles + Plans + Quotas) for this Next.js application.
5
- Covers user roles, team roles, theme extensions, permission checking, and RLS integration.
5
+ Covers user roles, team roles (stored as TEXT), theme role extensions, permission checking,
6
+ and LIST/READ fail-closed enforcement at the entity handler.
6
7
  Use this skill when implementing or modifying access control features.
8
+ For the runtime RLS model (service pool, nextspark_app cutover) see rls-enforcement.
7
9
  allowed-tools: Read, Glob, Grep
8
- version: 1.0.0
10
+ version: 1.1.0
9
11
  ---
10
12
 
11
13
  # Permissions System Skill
@@ -553,6 +555,13 @@ const customers = await queryWithRLS(
553
555
  )
554
556
  ```
555
557
 
558
+ **Enforcement at the entity handler (beta.167):** the generic handler now checks the session
559
+ permission on **LIST and READ** too (previously only create/update/delete) — a member without
560
+ `entity.list`/`entity.read` gets **403**. A permission check that **throws** returns
561
+ `500 PERMISSION_CHECK_FAILED` (**fail-closed**, never "allow"). Themes must declare `list`/`read`
562
+ per entity (`default`/`starter` already do). Note `team_role` is stored as **TEXT** (themes add
563
+ roles via config, not `ALTER TYPE`). For the runtime RLS model see the **rls-enforcement** skill.
564
+
556
565
  ## Build-Time Registry
557
566
 
558
567
  ```typescript
@@ -0,0 +1,192 @@
1
+ ---
2
+ name: rls-enforcement
3
+ description: |
4
+ Operating Row-Level Security at RUNTIME: the two-pool service-context model
5
+ (DATABASE_SERVICE_URL bypass vs the app pool), the non-owner runtime role (nextspark_app)
6
+ and cutover, MIGRATE_DATABASE_URL, fail-closed LIST/READ enforcement, and ownershipFilter.
7
+ Use this WHENEVER a DB call mysteriously returns 0 rows (or too many) for some users, a
8
+ query/insert started failing after switching DATABASE_URL, a webhook / scheduled-action /
9
+ login path hits a Postgres permission error, you're choosing between the app pool and the
10
+ service (bypass) pool, or composing a transaction that mixes user-scoped and system writes —
11
+ even if the words "RLS" or "cutover" never appear. Also for forcing the service pool
12
+ (team/subscription bootstrap), doing/debugging the cutover, and extending core RLS policies
13
+ from a theme. For policy SQL see database-migrations; for queryWithRLS usage see
14
+ service-layer; for can_bypass_rls/superadmin see api-bypass-layers.
15
+ allowed-tools: Read, Glob, Grep
16
+ version: 1.1.0
17
+ ---
18
+
19
+ # RLS Enforcement Layer Skill
20
+
21
+ **Canonical reference (read for SQL + cutover runbook):**
22
+ `packages/core/docs/10-backend/03-rls-policies.md` → "Enforcement Layer (beta.167+)".
23
+ This skill is the agent-facing companion: the operational rules and gotchas, not a
24
+ re-paste of the SQL.
25
+
26
+ ## When to use this skill
27
+
28
+ - Adding/auditing a DB call and deciding **app pool (RLS) vs service pool (bypass)**.
29
+ - Anything that writes the **first** team membership/subscription (bootstrap).
30
+ - Doing or debugging the **RLS cutover** (`nextspark_app`, `DATABASE_SERVICE_URL`, `MIGRATE_DATABASE_URL`).
31
+ - A theme that needs to **override a core RLS policy** (e.g. widen a tier on `users`/`subscriptions`).
32
+ - Touching `generic-handler.ts` permission checks or `ownershipFilter`.
33
+
34
+ ## 1. Two pools, routed by presence of `userId` (`src/lib/db.ts`)
35
+
36
+ | Helper | With `userId` | Without `userId` |
37
+ |---|---|---|
38
+ | `queryWithRLS` / `queryOneWithRLS` / `mutateWithRLS` / `getTransactionClient` | **app pool** (`SET LOCAL app.user_id`, RLS evaluated) | **service pool** (bypass) |
39
+
40
+ - **`query` / `queryOne` / `queryRows` ALWAYS go to the service pool (bypass).** They are
41
+ system operations by definition. **Never** use them for a user-scoped read that must be
42
+ RLS-filtered — use `queryWithRLS(sql, params, userId)`.
43
+ - **Force service even with a `userId`:** `mutateWithRLS(sql, params, userId, { service: true })`
44
+ or `getServiceTransactionClient()`.
45
+ - `DATABASE_SERVICE_URL` falls back to `DATABASE_URL` when unset → pre-cutover behavior is
46
+ unchanged (the app still connects as owner; nothing breaks).
47
+ - **A transaction is bound to ONE pool + ONE GUC at acquisition.** `getTransactionClient(userId)`
48
+ / `getServiceTransactionClient()` acquire a single client up front; every `tx.query/.mutate`
49
+ runs on that pool. You **cannot** do a user-scoped RLS read and a service-pool bootstrap write
50
+ in the *same* `tx`. If a flow needs both, run the bootstrap as a service transaction and do
51
+ the RLS reads/writes as separate `queryWithRLS(..., userId)` calls (or sequence them: commit
52
+ the membership first, then the user-scoped write can satisfy RLS).
53
+ - **Atomicity:** `mutateWithRLS` wraps **each call** in its own BEGIN/COMMIT — N calls = N
54
+ transactions. For "insert order + 3 items atomically", use `getTransactionClient(userId)`
55
+ (one GUC, one commit/rollback), not N `mutateWithRLS` calls.
56
+
57
+ ## 2. When you MUST force the service pool
58
+
59
+ Bootstrap writes that create the **first** membership/subscription of a team can't pass the
60
+ membership-based RLS insert policies under the new member's own GUC (they aren't a member
61
+ yet). These run on the **service pool**, authorized at the API/action layer (not RLS):
62
+
63
+ - `TeamService.create`, `teams/actions.ts:createTeam`, `addUserToGlobalTeam`,
64
+ `TeamMemberService.add` (via `{ service: true }`).
65
+
66
+ If you add a new "first-membership" writer, route it the same way or it will break under RLS.
67
+
68
+ **Other legitimate service-pool actors** (no request user → service pool by routing, since they
69
+ call with no `userId`): Better Auth login/verification, the **scheduled-actions processor**,
70
+ **payment-provider webhooks**, the superadmin/developer bypass check. Inside such a system path,
71
+ any *user-scoped* sub-operation must re-introduce the resolved userId via
72
+ `queryWithRLS(..., userId)` so RLS applies for that slice.
73
+
74
+ > ⚠️ **The inverse is a security hole.** Force the service pool **only** for work that
75
+ > *structurally* cannot satisfy RLS (first-membership/subscription, pre-login auth, webhooks/
76
+ > scheduler with no user). **Never** add `{ service: true }` to a normal user-scoped read/write
77
+ > to "fix" a 0-rows or permission-denied bug — that silently disables tenant isolation and leaks
78
+ > cross-tenant data. A 0-rows bug means a missing policy / missing `userId` / missing
79
+ > `entity.list` permission — fix *that*, don't bypass RLS.
80
+
81
+ ## 3. Runtime role + cutover gotcha
82
+
83
+ - Migration `022_rls_runtime_roles.sql` creates `nextspark_app` (non-owner, member of
84
+ `authenticated`, **no** `BYPASSRLS`) + grants + `anon` lockdown.
85
+ - **Migrations run as the OWNER** via `MIGRATE_DATABASE_URL` (falls back to `DATABASE_URL`);
86
+ the runtime runs as `nextspark_app`.
87
+ - **GOTCHA:** post-cutover `DATABASE_URL` is a **non-owner** role — any op that assumed owner
88
+ (raw DDL, seeds, `query()` expecting full access) behaves differently. Keep owner work on
89
+ the migrate/service connection.
90
+ - Cutover is **opt-in**: leave `DATABASE_SERVICE_URL` unset and nothing changes.
91
+
92
+ ## 4. Fail-closed permission enforcement (`generic-handler.ts`)
93
+
94
+ - LIST and READ now check the session entity permission (was create/update/delete only).
95
+ A member without `entity.list`/`entity.read` → **403**. Admin-bypass and api-key (scope)
96
+ paths are skipped.
97
+ - A **thrown error** in the permission check returns **`500 PERMISSION_CHECK_FAILED`**
98
+ (fail-closed) — never "allow". Themes must declare `list`/`read` per entity
99
+ (`default`/`starter` already do).
100
+ - **api-key vs session — two different layers, two different behaviors:**
101
+ - The **entity-permission** check is session-only (api-key uses scopes), so it's *skipped* for api-key.
102
+ - But **DB RLS still applies to api-key** requests — api-key auth carries a `userId`, so reads
103
+ route to the app pool and policies evaluate (team isolation holds).
104
+ - **`ownershipFilter` (role-based row scoping) is OFF for api-key and admin-bypass**
105
+ (`resolveOwnershipFilter` returns `{applies:false}` for those). So an api-key caller is *not*
106
+ narrowed to "their own" rows by `ownershipFilter`; they see everything their team-RLS policy
107
+ allows. To restrict api-key callers, scope the key narrowly or enforce it in a DB RLS policy
108
+ (which fires for any app-pool request), not via `ownershipFilter`.
109
+
110
+ ## 5. `ownershipFilter` (entity `access` config, `entities/types.ts`)
111
+
112
+ - `linkedBy` is **optional** → omit for **direct-field** ownership (entity row carries the
113
+ owner id; filtered against the current user).
114
+ - `linkedBy.softDelete` makes the `deletedAt IS NULL` clause conditional — set `false` for
115
+ tables without a `deletedAt` column (avoids a 42703 undefined-column error).
116
+
117
+ ## Debugging & validating
118
+
119
+ **"Returned 0 rows after the cutover" (worked before, member gets `[]`, superadmin still sees all).**
120
+ RLS is now live. Triage in order:
121
+ 1. **403 vs `[]`?** A `403` = fail-closed permission (member lacks `entity.list`/`read`, or theme
122
+ didn't declare it). An empty `[]` (200) = permission passed but RLS filtered all rows. They
123
+ are different bugs.
124
+ 2. **Is a `userId` actually passed?** A missing `userId` routes to the service pool → would
125
+ *over*-return, not under-return. So `[]` means a `userId` *is* present and RLS is filtering.
126
+ 3. **Is `DATABASE_SERVICE_URL` set to a real bypass/owner role?** If unset, the "service" pool is
127
+ *also* `nextspark_app` (no bypass) → even system reads get filtered. Most common cutover
128
+ mistake. Set it.
129
+ 4. **Does a policy exist?** RLS-enabled **with no policy** blocks ALL rows. Confirm a policy
130
+ `TO authenticated` exists and `nextspark_app` is a member of `authenticated` (it is, per 022).
131
+ 5. **Reproduce as the runtime role** (below). If empty there too, it's the policy / membership /
132
+ `ownershipFilter`, not the app wiring.
133
+
134
+ **Validate/test RLS as the real runtime role (not the owner — owner bypasses RLS, proving nothing).**
135
+ `nextspark_app` is NOLOGIN, but `022` runs `GRANT nextspark_app TO current_user`, so the
136
+ test/migrate user can assume it:
137
+ ```sql
138
+ BEGIN;
139
+ SET LOCAL ROLE nextspark_app;
140
+ SET LOCAL app.user_id = '<teamA_member_id>';
141
+ SELECT count(*) FROM "invoices" WHERE "teamId" = '<teamB_id>'; -- expect 0 (cross-tenant denied)
142
+ COMMIT;
143
+ ```
144
+ Always include a positive control (same query as a team-B member → > 0), or a `0` is meaningless.
145
+ Seed fixtures via the service pool / owner (first-membership inserts can't satisfy RLS).
146
+
147
+ ## Non-obvious rules (do not relearn the hard way)
148
+
149
+ - **RLS primitives live in migration `001`** (`can_bypass_rls`, `auth_user_can_see_user`,
150
+ `is_superadmin`) — because the hardened policies in `002` use them, and `002` runs before
151
+ the tables/functions a later migration would define. A policy with a forward dependency on
152
+ a later table fails at `CREATE POLICY` time; wrap such logic in a `SECURITY DEFINER` helper
153
+ defined in `001` (table refs resolve at call time). Cite `001`, not the stale `002:102`
154
+ comment that still says "010".
155
+ - **`team_role` is `TEXT`** (not an ENUM). Themes add roles via config
156
+ (`availableTeamRoles` + `permissions.config.ts`) — **never** `ALTER TYPE team_role`.
157
+ - **Theme override of a core RLS policy** runs in **phase 2** (after core). It must `DROP`
158
+ the core policy by name and recreate its own — dropping **both** the legacy AND the current
159
+ core policy names, or the policies **coexist** (RLS combines them with OR, loosening the
160
+ restriction). See campus `9035/9036` for the pattern.
161
+ - **Don't break the metadata self-read policy** (`003_user_metas`): login reads the user's own
162
+ flags under RLS via `MetaService.getEntityMetas(..., userId)`.
163
+ - **`anon` is not the app's public-read path.** Public reads enter with `userId=null` →
164
+ service pool (bypass), filtered in SQL by the handler. `TO anon` policies + the `022`
165
+ `anon` revoke are defense for the **Supabase Data API / PostgREST**, not app traffic.
166
+
167
+ ## Anti-patterns
168
+
169
+ - ❌ Using bare `query()`/`queryOne()` for a user-scoped read that must be RLS-filtered (it
170
+ bypasses RLS via the service pool).
171
+ - ❌ Adding a hardened policy that references `team_members`/`can_bypass_rls` to an **early**
172
+ migration (e.g. 002) without a helper in 001 → forward-dependency failure.
173
+ - ❌ `ALTER TYPE team_role ADD VALUE ...` in a theme → the type no longer exists.
174
+ - ❌ A theme override that drops only the legacy core policy names → coexisting policies.
175
+ - ❌ Returning "allow" when a permission check throws → must be fail-closed.
176
+
177
+ ## Checklist (the cross-cutting ones the body's rules don't already make obvious)
178
+
179
+ - [ ] DB call routing chosen deliberately: user-scoped → `queryWithRLS(..., userId)`; system → `query*`/no-userId; bootstrap-only → `{ service: true }`. **Not** `{ service: true }` to silence a 0-rows bug.
180
+ - [ ] No transaction tries to mix a user-scoped (app-pool) op and a service-pool (bootstrap) op on the same `tx`.
181
+ - [ ] Multi-row atomic write uses `getTransactionClient(userId)`, not N `mutateWithRLS` calls.
182
+ - [ ] New core-table policy: primitive deps already defined in `001` before the migration that uses them.
183
+ - [ ] New entity exposes `list`/`read` permissions (else 403 under enforcement); api-key callers restricted by scope or a DB policy (not `ownershipFilter`).
184
+ - [ ] Theme RLS override drops legacy AND current core policy names.
185
+
186
+ ## Related skills
187
+
188
+ - `database-migrations` — RLS policy SQL patterns, migration structure.
189
+ - `service-layer` — `queryWithRLS`/`mutateWithRLS` usage and service classes.
190
+ - `api-bypass-layers` — `can_bypass_rls()`, superadmin/developer bypass.
191
+ - `permissions-system` — team roles, tiers, permission checks.
192
+ - `scheduled-actions` — background/webhook handlers (service-pool actors with no request user).
@@ -0,0 +1,24 @@
1
+ # rls-enforcement — eval results
2
+
3
+ Methodology: anthropics-skill-creator (autonomous loop; subagents with-skill vs baseline,
4
+ graded against the per-case assertions in evals.json).
5
+
6
+ ## Iteration 1 — core cases (evals.json #0-4)
7
+ With-skill completed runs: 4/4 PASS (3/3 assertions each). Baseline (no skill, repo access)
8
+ also strong (the code + sibling skills are well-documented), so easy cases SATURATE — no
9
+ headroom. Conclusion: measure the skill on HARDER composite/debugging scenarios.
10
+
11
+ ## Iteration 1b — harder gap cases (current skill, pre-improvement)
12
+ gap1 tx-pool-binding: not covered by skill. gap2 0-rows: PASS (agent derived). gap3 api-key:
13
+ PARTIAL 2/3 — agent explicitly hedged ("skill doesn't specify api-key routing"). gap4
14
+ don't-over-service: PASS. gap8 test-as-nextspark_app: PASS (derived).
15
+
16
+ ## Improvements applied (deltas A-F + description)
17
+ A tx bound to one pool+GUC / atomicity; B api-key vs session asymmetry; C "0 rows after cutover"
18
+ triage + test-as-nextspark_app mechanics; D inverse footgun (don't over-service); E lean checklist;
19
+ F webhooks/scheduler actors + related skill; sharper symptom-based description.
20
+
21
+ ## Iteration 2 — same gap cases (improved skill)
22
+ 5/5 PASS (15/15 assertions). gap1 now a precise PASS; gap3 PASS 3/3 with NO hedge (the
23
+ skill-attributable win). Pass-rate on completed runs: ~87.5% -> 100%. Agents now cite the
24
+ skill's encoded sections instead of deriving (lower reasoning burden / higher reliability).
@@ -0,0 +1,56 @@
1
+ {
2
+ "skill_name": "rls-enforcement",
3
+ "notes": "Autonomous eval. Each case is a realistic NextSpark developer task whose CORRECT answer depends on knowledge in the rls-enforcement skill. We compare with-skill vs no-skill (baseline), grade against objective assertions, then improve and re-run to measure pass-rate delta.",
4
+ "evals": [
5
+ {
6
+ "id": 0,
7
+ "name": "bootstrap-service-pool",
8
+ "prompt": "In @nextsparkjs/core I'm adding a server action `createOrganization(userId, name)` that, in one DB transaction, inserts a new row in `teams` (ownerId = userId), inserts the creator's `team_members` row with role 'owner', and inserts a default `subscriptions` row for the team. The app will run with RLS enabled (DATABASE_URL connects as the non-owner role). Write the data-access code (which db.ts helper do you use and why).",
9
+ "assertions": [
10
+ {"text": "Uses the SERVICE pool (getServiceTransactionClient or { service: true } or a non-userId service path), NOT getTransactionClient(userId)/app pool"},
11
+ {"text": "Explains the reason: it creates the FIRST team_members/subscription so it cannot satisfy the membership-based RLS insert policy under the user's own GUC"},
12
+ {"text": "Notes that authorization is enforced at the app/action layer, not by RLS, for this bootstrap"}
13
+ ]
14
+ },
15
+ {
16
+ "id": 1,
17
+ "name": "bare-query-bypass",
18
+ "prompt": "A teammate wrote this helper to list the current user's own invoices in @nextsparkjs/core:\n\nimport { query } from '@/core/lib/db'\nexport async function listMyInvoices(userId: string) {\n return (await query('SELECT * FROM \"invoices\" WHERE \"userId\" = $1', [userId])).rows\n}\n\nUnder the RLS runtime, is this correct? If not, fix it and explain.",
19
+ "assertions": [
20
+ {"text": "Identifies that the bare query()/queryOne()/queryRows() family routes to the SERVICE pool and BYPASSES RLS"},
21
+ {"text": "Recommends queryWithRLS(sql, params, userId) so the query runs on the app pool with the GUC / RLS evaluated"},
22
+ {"text": "Frames it as a real risk (RLS not enforced) rather than a style nit"}
23
+ ]
24
+ },
25
+ {
26
+ "id": 2,
27
+ "name": "team-role-text-extension",
28
+ "prompt": "Our theme needs a new team role called 'reviewer'. How do I make it real so it can be stored in team_members.role and recognized by permission checks? Do I need a migration?",
29
+ "assertions": [
30
+ {"text": "States that team_role is TEXT (not a Postgres ENUM), so no ALTER TYPE / no DB migration is needed to add the value"},
31
+ {"text": "Says to declare the role via config (availableTeamRoles in app.config + permissions.config.ts)"},
32
+ {"text": "Explicitly warns against ALTER TYPE team_role ADD VALUE"}
33
+ ]
34
+ },
35
+ {
36
+ "id": 3,
37
+ "name": "policy-primitive-ordering",
38
+ "prompt": "I'm hardening the RLS policy on the `account` table by editing migration 002_auth_tables.sql to use `public.can_bypass_rls()`. On a fresh db:reset it fails with: ERROR: function public.can_bypass_rls() does not exist. Why, and how do I fix it without an incremental migration?",
39
+ "assertions": [
40
+ {"text": "Explains the cause: can_bypass_rls (and friends) must exist BEFORE migration 002 runs; defining them later (e.g. 010) is a forward dependency that fails at CREATE POLICY time"},
41
+ {"text": "Fix: define the primitive in migration 001 (the foundational functions) so 002 can use it"},
42
+ {"text": "Notes that a SECURITY DEFINER function's table refs resolve at call time, so it can be defined before those tables exist"}
43
+ ]
44
+ },
45
+ {
46
+ "id": 4,
47
+ "name": "theme-override-coexistence",
48
+ "prompt": "My theme ships a phase-2 migration that overrides the core `subscriptions` SELECT policy with a stricter campus-specific one. But after db:reset, a plain team member can still read the team's subscriptions as if the core policy were still active. What's wrong and how do I fix the override?",
49
+ "assertions": [
50
+ {"text": "Explains that RLS combines multiple permissive policies with OR, so the core policy and the theme policy COEXIST (the looser core one still grants access)"},
51
+ {"text": "Fix: the theme override must DROP the core policy by name — and drop BOTH the legacy AND the current (beta.167) core policy names — before creating its own"},
52
+ {"text": "Mentions the theme override runs in phase 2 (after core) which is why it must drop the core's policies"}
53
+ ]
54
+ }
55
+ ]
56
+ }
@@ -2,10 +2,11 @@
2
2
  name: service-layer
3
3
  description: |
4
4
  Service layer patterns for this Next.js application.
5
- Covers static class pattern, RLS integration, standard method signatures, and BaseEntityService.
5
+ Covers static class pattern, passing the RLS userId context (queryWithRLS), standard method signatures, and BaseEntityService.
6
6
  Use this skill when implementing business logic or data access services.
7
+ For the runtime RLS model (service pool vs app pool, when to force service) see rls-enforcement.
7
8
  allowed-tools: Read, Glob, Grep
8
- version: 1.0.0
9
+ version: 1.1.0
9
10
  ---
10
11
 
11
12
  # Service Layer Skill
@@ -203,6 +204,15 @@ export async function mutateWithRLS<T>(
203
204
  - RLS policies use this to filter/restrict data access
204
205
  - Always pass `userId` parameter for proper isolation
205
206
 
207
+ **Dual-pool routing (beta.167) — see the `rls-enforcement` skill:**
208
+ - `*WithRLS` **with** a `userId` → app pool (RLS evaluated). **Without** a `userId` → **service
209
+ pool (bypass)**. The bare `query`/`queryOne`/`queryRows` family is **always** service/bypass.
210
+ - **System/bootstrap writes** that create the first team membership/subscription must force the
211
+ service pool: `mutateWithRLS(sql, params, userId, { service: true })` or
212
+ `getServiceTransactionClient()` (they can't satisfy membership-based insert policies under the
213
+ new member's own GUC). This is why `TeamService.create` / `TeamMemberService.add` use the
214
+ service path.
215
+
206
216
  ### Service Implementation with RLS
207
217
 
208
218
  ```typescript
@@ -585,3 +595,4 @@ Before finalizing service implementation:
585
595
  - `entity-api` - API endpoint patterns
586
596
  - `permissions-system` - Permission checking
587
597
  - `database-migrations` - Migration patterns
598
+ - `rls-enforcement` - Runtime RLS: service pool vs app pool, when to force service, cutover
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextsparkjs/ai-workflow",
3
- "version": "0.1.0-beta.166",
3
+ "version": "0.1.0-beta.167",
4
4
  "description": "AI workflow templates for NextSpark - Claude Code agents, commands, skills, and multi-editor support",
5
5
  "license": "MIT",
6
6
  "author": "NextSpark <hello@nextspark.dev>",
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 NextSpark
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.