@eventmodelers/node-kit 0.0.11 → 0.0.12

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 (45) hide show
  1. package/package.json +1 -1
  2. package/templates/.claude/skills/build-automation/SKILL.md +260 -0
  3. package/templates/.claude/skills/build-state-change/SKILL.md +329 -0
  4. package/templates/.claude/skills/build-state-view/SKILL.md +384 -0
  5. package/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +609 -0
  6. package/templates/.claude/skills/load-slice/SKILL.md +69 -14
  7. package/templates/realtime-agent/src/index.js +11 -1
  8. package/templates/root/.env.example +22 -0
  9. package/templates/root/Claude.md +58 -0
  10. package/templates/root/agent.sh +15 -0
  11. package/templates/root/backend-prompt.md +139 -0
  12. package/templates/root/flyway.conf +17 -0
  13. package/templates/root/package.json +52 -0
  14. package/templates/root/ralph.sh +47 -26
  15. package/templates/root/server.ts +213 -0
  16. package/templates/root/setup-env.sh +55 -0
  17. package/templates/root/src/common/assertions.ts +6 -0
  18. package/templates/root/src/common/db.ts +32 -0
  19. package/templates/root/src/common/loadPostgresEventstore.ts +39 -0
  20. package/templates/root/src/common/parseEndpoint.ts +51 -0
  21. package/templates/root/src/common/processorDlq.ts +28 -0
  22. package/templates/root/src/common/realtimeBroadcast.ts +19 -0
  23. package/templates/root/src/common/replay.ts +16 -0
  24. package/templates/root/src/common/routes.ts +19 -0
  25. package/templates/root/src/common/testHelpers.ts +54 -0
  26. package/templates/root/src/slices/example/routes.ts +134 -0
  27. package/templates/root/src/supabase/LoginHandler.ts +36 -0
  28. package/templates/root/src/supabase/ProtectedPageProps.ts +21 -0
  29. package/templates/root/src/supabase/README.md +171 -0
  30. package/templates/root/src/supabase/api.ts +56 -0
  31. package/templates/root/src/supabase/component.ts +12 -0
  32. package/templates/root/src/supabase/requireOrgaAdmin.ts +32 -0
  33. package/templates/root/src/supabase/requireUser.ts +72 -0
  34. package/templates/root/src/supabase/serverProps.ts +25 -0
  35. package/templates/root/src/supabase/staticProps.ts +10 -0
  36. package/templates/root/src/swagger.ts +34 -0
  37. package/templates/root/src/util/assertions.ts +6 -0
  38. package/templates/root/src/util/hash.ts +9 -0
  39. package/templates/root/src/util/sanitize.ts +23 -0
  40. package/templates/root/supabase/config.toml +295 -0
  41. package/templates/root/supabase/migrations/V1__schema.sql.example +12 -0
  42. package/templates/root/supabase/seed.sql +1 -0
  43. package/templates/root/tsconfig.json +32 -0
  44. package/templates/root/vercel.json +8 -0
  45. package/templates/root/model.md +0 -1
@@ -0,0 +1,36 @@
1
+ import {type EmailOtpType} from '@supabase/supabase-js'
2
+ import {Request, Response} from "express";
3
+
4
+ import createClient from './api'
5
+
6
+ function stringOrFirstString(item: string | string[] | undefined) {
7
+ return Array.isArray(item) ? item[0] : item
8
+ }
9
+
10
+ export default async function LoginHandler(req: Request, res: Response) {
11
+ if (req.method !== 'GET') {
12
+ res.status(405).appendHeader('Allow', 'GET').end()
13
+ return
14
+ }
15
+
16
+ const queryParams = req.query
17
+ const token_hash = stringOrFirstString(queryParams["token_hash"]?.toString())
18
+ const type = stringOrFirstString(queryParams["type"]?.toString())
19
+
20
+ let next = '/error'
21
+
22
+ if (token_hash && type) {
23
+ const supabase = createClient()
24
+ const {error} = await supabase.auth.verifyOtp({
25
+ type: type as EmailOtpType,
26
+ token_hash,
27
+ })
28
+ if (error) {
29
+ console.error(error)
30
+ } else {
31
+ next = stringOrFirstString(queryParams["next"]?.toString()) || '/'
32
+ }
33
+ }
34
+
35
+ res.redirect(next)
36
+ }
@@ -0,0 +1,21 @@
1
+ import {type Request, type Response} from "express";
2
+ import {createClient} from "./serverProps";
3
+
4
+ export async function getAuthenticatedUser(req: Request, res: Response) {
5
+ const supabase = createClient(req, res)
6
+ const {data, error} = await supabase.auth.getUser()
7
+ if (error || !data) {
8
+ return null
9
+ }
10
+ return data.user
11
+ }
12
+
13
+ export async function requireAuthMiddleware(req: Request, res: Response, next: () => void) {
14
+ const user = await getAuthenticatedUser(req, res)
15
+ if (!user) {
16
+ res.redirect('/auth/login')
17
+ return
18
+ }
19
+ (req as any).user = user
20
+ next()
21
+ }
@@ -0,0 +1,171 @@
1
+ # Supabase JWT Authentication for Backend API
2
+
3
+ This backend API uses Supabase JWT tokens for authentication. Clients must include a valid JWT token in the
4
+ `Authorization` header.
5
+
6
+ ## Quick Start
7
+
8
+ 1. **Set up environment variables** in `.env.local`:
9
+ ```env
10
+ NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
11
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
12
+ ```
13
+
14
+ 2. **Start the server**: `npm run dev`
15
+
16
+ 3. **Get a test JWT token**:
17
+ - Visit http://localhost:3000/auth/login
18
+ - Create an account or login
19
+ - Copy the JWT token displayed
20
+ - Use it in your API requests
21
+
22
+ ## Test Login Page
23
+
24
+ A simple test login page is available at `/auth/login` that:
25
+
26
+ - Allows you to create accounts or login
27
+ - Displays the JWT token after authentication
28
+ - Provides a "Test API Call" button
29
+ - Shows a cURL example for testing
30
+
31
+ This page is for testing purposes only.
32
+
33
+ ## How It Works
34
+
35
+ 1. **Client obtains JWT token** from Supabase (via your frontend app)
36
+ 2. **Client sends requests** with `Authorization: Bearer <jwt-token>` header
37
+ 3. **Backend verifies JWT** using Supabase and extracts user info
38
+ 4. **Protected routes** return user data or 401 Unauthorized
39
+
40
+ ## Usage Examples
41
+
42
+ ### Option 1: Using `requireUser` function
43
+
44
+ ```typescript
45
+ import {requireUser} from './src/supabase/requireUser';
46
+ import {Request, Response} from 'express';
47
+
48
+ app.get('/api/protected', async (req: Request, res: Response) => {
49
+ const result = await requireUser(req, res, false);
50
+
51
+ if (result.error) {
52
+ return res.status(401).json({error: result.error});
53
+ }
54
+
55
+ const user = result.user;
56
+ res.json({
57
+ message: 'Protected data',
58
+ userId: user.id,
59
+ email: user.email
60
+ });
61
+ });
62
+ ```
63
+
64
+ ### Option 2: Using `authMiddleware`
65
+
66
+ ```typescript
67
+ import {authMiddleware} from './src/supabase/authMiddleware';
68
+
69
+ app.get('/api/protected', authMiddleware, (req, res) => {
70
+ // User is available on req.user after middleware
71
+ const user = (req as any).user;
72
+
73
+ res.json({
74
+ message: 'Protected data',
75
+ user: user
76
+ });
77
+ });
78
+ ```
79
+
80
+ ### Testing with curl
81
+
82
+ ```bash
83
+ # Get JWT token from your Supabase client first
84
+ TOKEN="your-jwt-token-here"
85
+
86
+ # Test protected endpoint
87
+ curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/user
88
+
89
+ # Expected response:
90
+ # {
91
+ # "userId": "...",
92
+ # "email": "user@example.com",
93
+ # "metadata": { ... }
94
+ # }
95
+ ```
96
+
97
+ ### Testing with JavaScript fetch
98
+
99
+ ```javascript
100
+ const token = supabase.auth.session()?.access_token;
101
+
102
+ fetch('http://localhost:3000/api/user', {
103
+ headers: {
104
+ 'Authorization': `Bearer ${token}`
105
+ }
106
+ })
107
+ .then(res => res.json())
108
+ .then(data => console.log(data));
109
+ ```
110
+
111
+ ## API Endpoints
112
+
113
+ ### `GET /api/user`
114
+
115
+ Returns current authenticated user information.
116
+
117
+ **Headers:**
118
+
119
+ - `Authorization: Bearer <jwt-token>` (required)
120
+
121
+ **Success Response (200):**
122
+
123
+ ```json
124
+ {
125
+ "userId": "uuid",
126
+ "email": "user@example.com",
127
+ "metadata": { ... }
128
+ }
129
+ ```
130
+
131
+ **Error Responses:**
132
+
133
+ - `401 Unauthorized`: Missing or invalid token
134
+ - `500 Internal Server Error`: Server error
135
+
136
+ ## Files
137
+
138
+ - **`api.ts`**: Supabase client creation
139
+ - **`requireUser.ts`**: JWT verification function
140
+ - **`authMiddleware.ts`**: Express middleware for protecting routes
141
+ - **`README.md`**: This documentation
142
+
143
+ ## Architecture
144
+
145
+ ```
146
+ Client Request
147
+ |
148
+ v
149
+ Authorization: Bearer <JWT>
150
+ |
151
+ v
152
+ Express Route
153
+ |
154
+ v
155
+ requireUser() / authMiddleware
156
+ |
157
+ v
158
+ Supabase JWT Verification
159
+ |
160
+ +---> Valid: Continue with user data
161
+ |
162
+ +---> Invalid: Return 401 Unauthorized
163
+ ```
164
+
165
+ ## Security Notes
166
+
167
+ - JWT tokens are verified with Supabase on every request
168
+ - No session storage on the backend (stateless)
169
+ - Tokens expire based on Supabase configuration
170
+ - Always use HTTPS in production
171
+ - Store the anon key securely (use environment variables)
@@ -0,0 +1,56 @@
1
+ import {createClient as createSupabaseClient} from '@supabase/supabase-js'
2
+ import {Request} from 'express'
3
+
4
+ let _serviceClient: ReturnType<typeof createSupabaseClient> | null = null;
5
+
6
+ export const createServiceClient = () => {
7
+ if (!_serviceClient) {
8
+ _serviceClient = createSupabaseClient(
9
+ process.env.SUPABASE_URL!,
10
+ process.env.SUPABASE_SECRET_KEY!
11
+ );
12
+ }
13
+ return _serviceClient;
14
+ }
15
+
16
+ /**
17
+ * Creates a Supabase client for verifying JWT tokens
18
+ * Used in backend API endpoints
19
+ */
20
+ export default function createClient() {
21
+ return createSupabaseClient(
22
+ process.env.SUPABASE_URL!,
23
+ process.env.SUPABASE_PUBLISHABLE_KEY!,
24
+ {
25
+ auth: {
26
+ persistSession: false,
27
+ autoRefreshToken: false,
28
+ detectSessionInUrl: false,
29
+ },
30
+ }
31
+ )
32
+ }
33
+
34
+ /**
35
+ * Creates an authenticated Supabase client with the user's JWT token
36
+ * This ensures Row Level Security (RLS) policies are applied with the user's context
37
+ */
38
+ export async function createAuthenticatedClient(req: Request) {
39
+ const token = req.headers.authorization?.replace('Bearer ', '') || '';
40
+ return createSupabaseClient(
41
+ process.env.SUPABASE_URL!,
42
+ process.env.SUPABASE_PUBLISHABLE_KEY!,
43
+ {
44
+ auth: {
45
+ persistSession: false,
46
+ autoRefreshToken: false,
47
+ detectSessionInUrl: false,
48
+ },
49
+ global: {
50
+ headers: {
51
+ Authorization: `Bearer ${token}`,
52
+ },
53
+ },
54
+ }
55
+ );
56
+ }
@@ -0,0 +1,12 @@
1
+ import {createBrowserClient} from '@supabase/ssr'
2
+
3
+ /**
4
+ * Creates a Supabase client for browser/client-side operations
5
+ * Used in React components for authentication flows
6
+ */
7
+ export function createClient() {
8
+ return createBrowserClient(
9
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
10
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
11
+ )
12
+ }
@@ -0,0 +1,32 @@
1
+ import {Response} from 'express';
2
+ import {SupabaseClient} from '@supabase/supabase-js';
3
+
4
+ type RequireOrgaAdminResult = {
5
+ ok: true;
6
+ error: null;
7
+ } | {
8
+ ok: false;
9
+ error: string;
10
+ };
11
+
12
+ export async function requireOrgaAdmin(
13
+ supabase: SupabaseClient,
14
+ orgaId: string,
15
+ userId: string,
16
+ res: Response,
17
+ ): Promise<RequireOrgaAdminResult> {
18
+ const {data, error} = await supabase
19
+ .from('user_organizations')
20
+ .select('role')
21
+ .eq('orga_id', orgaId)
22
+ .eq('user_id', userId)
23
+ .eq('role', 'admin')
24
+ .single();
25
+
26
+ if (error || !data) {
27
+ res.status(403).json({error: 'Forbidden: admin role required'});
28
+ return {ok: false, error: 'FORBIDDEN'};
29
+ }
30
+
31
+ return {ok: true, error: null};
32
+ }
@@ -0,0 +1,72 @@
1
+ import {createAuthenticatedClient} from "./api";
2
+ import {Request, Response} from "express"
3
+
4
+ type RequireUserResult = {
5
+ user: any;
6
+ error: null;
7
+ } | {
8
+ user: null;
9
+ error: string;
10
+ };
11
+
12
+ /**
13
+ * Extracts JWT token from Authorization header
14
+ * Supports "Bearer <token>" format
15
+ */
16
+ function extractTokenFromHeader(req: Request): string | null {
17
+ const authHeader = req.headers.authorization;
18
+
19
+ if (!authHeader) {
20
+ return null;
21
+ }
22
+
23
+ // Check for "Bearer <token>" format
24
+ const parts = authHeader.split(' ');
25
+ if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
26
+ return parts[1];
27
+ }
28
+
29
+ // If no Bearer prefix, assume the entire header is the token
30
+ return authHeader;
31
+ }
32
+
33
+ /**
34
+ * Verifies JWT token from Authorization header and returns user info
35
+ * This is for backend API use - does not use cookies or redirects
36
+ */
37
+ export async function requireUser(req: Request, resp: Response, sendUnauthorized: boolean = true): Promise<RequireUserResult> {
38
+ const token = extractTokenFromHeader(req);
39
+
40
+ if (!token) {
41
+ if (sendUnauthorized) {
42
+ resp.status(401).json({error: 'Missing authorization token'});
43
+ }
44
+ return {
45
+ user: null,
46
+ error: 'MISSING_TOKEN',
47
+ };
48
+ }
49
+
50
+ const supabase = await createAuthenticatedClient(req);
51
+
52
+ // Verify the JWT token
53
+ const {
54
+ data: {user},
55
+ error
56
+ } = await supabase.auth.getUser(token);
57
+
58
+ if (error || !user) {
59
+ if (sendUnauthorized) {
60
+ resp.status(401).json({error: 'Invalid or expired token'});
61
+ }
62
+ return {
63
+ user: null,
64
+ error: error?.message || 'UNAUTHORIZED',
65
+ };
66
+ }
67
+
68
+ return {
69
+ user: user,
70
+ error: null
71
+ }
72
+ }
@@ -0,0 +1,25 @@
1
+ import {type Request, type Response} from 'express'
2
+ import {createServerClient, serializeCookieHeader} from '@supabase/ssr'
3
+
4
+ export function createClient(req: Request, res: Response) {
5
+ const supabase = createServerClient(
6
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
7
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
8
+ {
9
+ cookies: {
10
+ getAll() {
11
+ return Object.keys(req.cookies).map((name) => ({name, value: req.cookies[name] || ''}))
12
+ },
13
+ setAll(cookiesToSet: any[]) {
14
+ res.setHeader(
15
+ 'Set-Cookie',
16
+ cookiesToSet.map(({name, value, options}: any) =>
17
+ serializeCookieHeader(name, value, options)
18
+ )
19
+ )
20
+ },
21
+ },
22
+ }
23
+ )
24
+ return supabase
25
+ }
@@ -0,0 +1,10 @@
1
+ import {createClient as createClientPrimitive} from '@supabase/supabase-js'
2
+
3
+ export function createClient() {
4
+ const supabase = createClientPrimitive(
5
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
6
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
7
+ )
8
+
9
+ return supabase
10
+ }
@@ -0,0 +1,34 @@
1
+ import swaggerJsdoc from 'swagger-jsdoc';
2
+
3
+ const options = {
4
+ definition: {
5
+ openapi: '3.0.0',
6
+ info: {
7
+ title: 'Context API',
8
+ version: '1.0.0',
9
+ description: 'Event-driven API for shift, clerk, and task management',
10
+ },
11
+ servers: [
12
+ {
13
+ url: 'http://localhost:3000',
14
+ description: 'Development server',
15
+ },
16
+ {
17
+ url: process.env.API_URL || 'http://localhost:3000',
18
+ description: 'Production server',
19
+ },
20
+ ],
21
+ components: {
22
+ securitySchemes: {
23
+ bearerAuth: {
24
+ type: 'http',
25
+ scheme: 'bearer',
26
+ bearerFormat: 'JWT',
27
+ },
28
+ },
29
+ },
30
+ },
31
+ apis: ['./src/slices/**/routes.ts'],
32
+ };
33
+
34
+ export const specs = swaggerJsdoc(options);
@@ -0,0 +1,6 @@
1
+ export function assertNotEmpty<T>(value: T): NonNullable<T> {
2
+ if (value === null || value === undefined) {
3
+ throw new Error("Expected non-empty value");
4
+ }
5
+ return value!!;
6
+ }
@@ -0,0 +1,9 @@
1
+ // Fast djb2-style hash of any object — used to detect drift
2
+ export function hashMeta(entry: unknown): string {
3
+ const str = JSON.stringify(entry);
4
+ let h = 5381;
5
+ for (let i = 0; i < str.length; i++) {
6
+ h = (((h << 5) + h) ^ str.charCodeAt(i)) >>> 0;
7
+ }
8
+ return h.toString(36);
9
+ }
@@ -0,0 +1,23 @@
1
+ function sanitizeValue(value: unknown): unknown {
2
+ if (typeof value === 'bigint') {
3
+ return Number.isSafeInteger(Number(value)) ? Number(value) : value.toString();
4
+ }
5
+ if (Array.isArray(value)) {
6
+ return value.map(sanitizeValue);
7
+ }
8
+ if (value !== null && typeof value === 'object') {
9
+ return Object.fromEntries(
10
+ Object.entries(value).map(([k, v]) => [k, sanitizeValue(v)])
11
+ );
12
+ }
13
+ return value;
14
+ }
15
+
16
+ export function sanitize<T>(value: T): unknown {
17
+ return sanitizeValue(value);
18
+ }
19
+
20
+ export const jsonBigIntReplacer = (_key: string, value: unknown): unknown =>
21
+ typeof value === 'bigint'
22
+ ? (Number.isSafeInteger(Number(value)) ? Number(value) : value.toString())
23
+ : value;