@stacksolo/plugin-gcp-kernel 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,508 @@
1
+ # @stacksolo/plugin-gcp-kernel
2
+
3
+ A GCP-native kernel plugin for StackSolo that provides authentication, file storage, and event publishing using fully serverless GCP services.
4
+
5
+ ## What is the GCP Kernel?
6
+
7
+ The GCP kernel is a shared infrastructure service that handles common operations for your apps. Unlike the regular kernel (which uses NATS), the GCP kernel uses native GCP services:
8
+
9
+ | Feature | GCP Kernel | Regular Kernel |
10
+ |---------|-----------|----------------|
11
+ | **Transport** | HTTP | HTTP + NATS |
12
+ | **Events** | Cloud Pub/Sub | NATS JetStream |
13
+ | **Deployment** | Cloud Run | Cloud Run + NATS |
14
+ | **Scaling** | Auto-scales to 0 | Needs min 1 instance |
15
+ | **Cost** | Pay-per-use | ~$44/mo always-on |
16
+
17
+ **The GCP kernel provides:**
18
+
19
+ 1. **Auth** - Validates Firebase tokens (so you know who's logged in)
20
+ 2. **Files** - Generates secure upload/download URLs for Google Cloud Storage
21
+ 3. **Events** - Publishes events to Cloud Pub/Sub
22
+
23
+ ---
24
+
25
+ ## Quick Start
26
+
27
+ ### Step 1: Add GCP kernel to your config
28
+
29
+ In your `stacksolo.config.json`:
30
+
31
+ ```json
32
+ {
33
+ "project": {
34
+ "name": "my-app",
35
+ "gcpProjectId": "my-gcp-project",
36
+ "region": "us-central1",
37
+ "plugins": [
38
+ "@stacksolo/plugin-gcp-cdktf",
39
+ "@stacksolo/plugin-gcp-kernel"
40
+ ],
41
+
42
+ "buckets": [
43
+ { "name": "uploads" }
44
+ ],
45
+
46
+ "gcpKernel": {
47
+ "name": "kernel",
48
+ "firebaseProjectId": "my-gcp-project",
49
+ "storageBucket": "uploads"
50
+ },
51
+
52
+ "networks": [{
53
+ "name": "default",
54
+ "containers": [{
55
+ "name": "api",
56
+ "env": {
57
+ "KERNEL_URL": "@gcp-kernel/kernel.url",
58
+ "KERNEL_TYPE": "gcp"
59
+ }
60
+ }]
61
+ }]
62
+ }
63
+ }
64
+ ```
65
+
66
+ ### Step 2: Deploy
67
+
68
+ ```bash
69
+ stacksolo deploy
70
+ ```
71
+
72
+ ### Step 3: Use in your code
73
+
74
+ ```typescript
75
+ // Validate a user's token
76
+ const response = await fetch(`${process.env.KERNEL_URL}/auth/validate`, {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ token: userFirebaseToken }),
80
+ });
81
+
82
+ const { valid, uid, email } = await response.json();
83
+
84
+ if (valid) {
85
+ console.log(`User ${uid} is logged in!`);
86
+ }
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Configuration Reference
92
+
93
+ Add this to your `stacksolo.config.json` under `project`:
94
+
95
+ ```json
96
+ {
97
+ "gcpKernel": {
98
+ "name": "kernel",
99
+ "firebaseProjectId": "your-firebase-project-id",
100
+ "storageBucket": "your-gcs-bucket-name"
101
+ }
102
+ }
103
+ ```
104
+
105
+ ### Required Fields
106
+
107
+ | Field | What it does | Example |
108
+ |-------|--------------|---------|
109
+ | `firebaseProjectId` | The Firebase project used for user authentication | `"my-app-prod"` |
110
+ | `storageBucket` | The GCS bucket for file uploads/downloads | `"my-app-uploads"` |
111
+
112
+ ### Optional Fields
113
+
114
+ | Field | Default | What it does |
115
+ |-------|---------|--------------|
116
+ | `name` | `"gcp-kernel"` | Name used in references like `@gcp-kernel/kernel` |
117
+ | `minInstances` | `0` | Minimum instances (0 = scale to zero) |
118
+ | `memory` | `"512Mi"` | Memory for the service |
119
+
120
+ ---
121
+
122
+ ## How to Reference the GCP Kernel
123
+
124
+ In your container or function config, use these references:
125
+
126
+ | Reference | What you get | Example value |
127
+ |-----------|--------------|---------------|
128
+ | `@gcp-kernel/kernel.url` | Base URL of the kernel | `https://kernel-abc123.run.app` |
129
+
130
+ **Example:**
131
+
132
+ ```json
133
+ {
134
+ "containers": [{
135
+ "name": "api",
136
+ "env": {
137
+ "KERNEL_URL": "@gcp-kernel/kernel.url",
138
+ "KERNEL_TYPE": "gcp"
139
+ }
140
+ }]
141
+ }
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Using the Auth Service
147
+
148
+ The auth service validates Firebase ID tokens via HTTP.
149
+
150
+ ### Code Example: Express Middleware
151
+
152
+ ```typescript
153
+ // middleware/auth.ts
154
+
155
+ export async function requireAuth(req, res, next) {
156
+ const authHeader = req.headers.authorization;
157
+
158
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
159
+ return res.status(401).json({ error: 'No token provided' });
160
+ }
161
+
162
+ const token = authHeader.replace('Bearer ', '');
163
+
164
+ try {
165
+ const response = await fetch(`${process.env.KERNEL_URL}/auth/validate`, {
166
+ method: 'POST',
167
+ headers: { 'Content-Type': 'application/json' },
168
+ body: JSON.stringify({ token }),
169
+ });
170
+
171
+ const data = await response.json();
172
+
173
+ if (!data.valid) {
174
+ return res.status(401).json({ error: data.error });
175
+ }
176
+
177
+ req.user = {
178
+ uid: data.uid,
179
+ email: data.email,
180
+ };
181
+
182
+ next();
183
+ } catch (error) {
184
+ console.error('Auth validation failed:', error);
185
+ return res.status(500).json({ error: 'Auth service unavailable' });
186
+ }
187
+ }
188
+ ```
189
+
190
+ ### Auth API Reference
191
+
192
+ **Endpoint:** `POST /auth/validate`
193
+
194
+ **Request:**
195
+ ```json
196
+ {
197
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6..."
198
+ }
199
+ ```
200
+
201
+ **Success Response (200):**
202
+ ```json
203
+ {
204
+ "valid": true,
205
+ "uid": "abc123",
206
+ "email": "user@example.com",
207
+ "claims": { ... }
208
+ }
209
+ ```
210
+
211
+ **Error Response (401):**
212
+ ```json
213
+ {
214
+ "valid": false,
215
+ "error": "Token has expired",
216
+ "code": "TOKEN_EXPIRED"
217
+ }
218
+ ```
219
+
220
+ ---
221
+
222
+ ## Using the Files Service
223
+
224
+ The files service provides HTTP endpoints for file operations.
225
+
226
+ ### Code Example: File Upload
227
+
228
+ **Backend:**
229
+
230
+ ```typescript
231
+ app.post('/api/files/upload-url', requireAuth, async (req, res) => {
232
+ const { filename, contentType } = req.body;
233
+ const path = `users/${req.user.uid}/uploads/${Date.now()}-${filename}`;
234
+
235
+ const response = await fetch(`${process.env.KERNEL_URL}/files/upload-url`, {
236
+ method: 'POST',
237
+ headers: { 'Content-Type': 'application/json' },
238
+ body: JSON.stringify({ path, contentType }),
239
+ });
240
+
241
+ const result = await response.json();
242
+ res.json(result);
243
+ });
244
+ ```
245
+
246
+ **Frontend:**
247
+
248
+ ```typescript
249
+ async function uploadFile(file: File, firebaseToken: string) {
250
+ // Get signed upload URL
251
+ const urlResponse = await fetch('/api/files/upload-url', {
252
+ method: 'POST',
253
+ headers: {
254
+ 'Authorization': `Bearer ${firebaseToken}`,
255
+ 'Content-Type': 'application/json',
256
+ },
257
+ body: JSON.stringify({
258
+ filename: file.name,
259
+ contentType: file.type,
260
+ }),
261
+ });
262
+
263
+ const { uploadUrl, path } = await urlResponse.json();
264
+
265
+ // Upload directly to GCS
266
+ await fetch(uploadUrl, {
267
+ method: 'PUT',
268
+ headers: { 'Content-Type': file.type },
269
+ body: file,
270
+ });
271
+
272
+ return path;
273
+ }
274
+ ```
275
+
276
+ ### Files API Reference (HTTP)
277
+
278
+ **POST /files/upload-url**
279
+
280
+ Request:
281
+ ```json
282
+ {
283
+ "path": "users/123/uploads/photo.jpg",
284
+ "contentType": "image/jpeg"
285
+ }
286
+ ```
287
+
288
+ Response:
289
+ ```json
290
+ {
291
+ "uploadUrl": "https://storage.googleapis.com/...",
292
+ "path": "users/123/uploads/photo.jpg",
293
+ "expiresAt": "2024-01-01T12:00:00.000Z"
294
+ }
295
+ ```
296
+
297
+ **POST /files/download-url**
298
+
299
+ Request:
300
+ ```json
301
+ {
302
+ "path": "users/123/uploads/photo.jpg"
303
+ }
304
+ ```
305
+
306
+ Response:
307
+ ```json
308
+ {
309
+ "downloadUrl": "https://storage.googleapis.com/...",
310
+ "path": "users/123/uploads/photo.jpg",
311
+ "expiresAt": "2024-01-01T12:00:00.000Z"
312
+ }
313
+ ```
314
+
315
+ **POST /files/list**
316
+
317
+ Request:
318
+ ```json
319
+ {
320
+ "prefix": "users/123/",
321
+ "maxResults": 100
322
+ }
323
+ ```
324
+
325
+ Response:
326
+ ```json
327
+ {
328
+ "files": [
329
+ {
330
+ "path": "users/123/photo.jpg",
331
+ "size": 12345,
332
+ "contentType": "image/jpeg",
333
+ "created": "2024-01-01T10:00:00.000Z",
334
+ "updated": "2024-01-01T10:00:00.000Z"
335
+ }
336
+ ],
337
+ "nextPageToken": null
338
+ }
339
+ ```
340
+
341
+ **POST /files/delete**
342
+
343
+ Request:
344
+ ```json
345
+ {
346
+ "path": "users/123/uploads/photo.jpg"
347
+ }
348
+ ```
349
+
350
+ **POST /files/move**
351
+
352
+ Request:
353
+ ```json
354
+ {
355
+ "sourcePath": "users/123/old-photo.jpg",
356
+ "destinationPath": "users/123/new-photo.jpg"
357
+ }
358
+ ```
359
+
360
+ **POST /files/metadata**
361
+
362
+ Request:
363
+ ```json
364
+ {
365
+ "path": "users/123/uploads/photo.jpg"
366
+ }
367
+ ```
368
+
369
+ ---
370
+
371
+ ## Using the Events Service
372
+
373
+ The events service publishes to Cloud Pub/Sub via HTTP.
374
+
375
+ ### Code Example: Publishing Events
376
+
377
+ ```typescript
378
+ async function publishEvent(type: string, data: any) {
379
+ await fetch(`${process.env.KERNEL_URL}/events/publish`, {
380
+ method: 'POST',
381
+ headers: { 'Content-Type': 'application/json' },
382
+ body: JSON.stringify({
383
+ type,
384
+ data,
385
+ metadata: {
386
+ source: 'api-service',
387
+ timestamp: new Date().toISOString(),
388
+ },
389
+ }),
390
+ });
391
+ }
392
+
393
+ // Usage
394
+ await publishEvent('user.signed-up', { userId: '123', email: 'user@example.com' });
395
+ ```
396
+
397
+ ### Events API Reference
398
+
399
+ **POST /events/publish**
400
+
401
+ Request:
402
+ ```json
403
+ {
404
+ "type": "user.signed-up",
405
+ "data": {
406
+ "userId": "123",
407
+ "email": "user@example.com"
408
+ },
409
+ "metadata": {
410
+ "source": "api-service"
411
+ }
412
+ }
413
+ ```
414
+
415
+ Response:
416
+ ```json
417
+ {
418
+ "messageId": "1234567890",
419
+ "published": true
420
+ }
421
+ ```
422
+
423
+ ---
424
+
425
+ ## GCP Kernel vs Regular Kernel
426
+
427
+ Choose GCP Kernel when:
428
+ - You want to scale to zero
429
+ - You don't need real-time messaging
430
+ - You want lower costs for light usage
431
+ - You prefer fully managed services
432
+
433
+ Choose Regular Kernel when:
434
+ - You need real-time pub/sub (WebSocket-style)
435
+ - You have consistent traffic
436
+ - You need NATS features (request/reply, JetStream)
437
+
438
+ ---
439
+
440
+ ## Local Development
441
+
442
+ ### Running with stacksolo dev
443
+
444
+ ```bash
445
+ stacksolo dev
446
+ ```
447
+
448
+ The GCP kernel will be built and run automatically if `gcpKernel` is in your config.
449
+
450
+ ### Testing the endpoints
451
+
452
+ ```bash
453
+ # Test health check
454
+ curl http://localhost:8080/health
455
+
456
+ # Test auth (requires Firebase token)
457
+ curl -X POST http://localhost:8080/auth/validate \
458
+ -H "Content-Type: application/json" \
459
+ -d '{"token":"your-firebase-token"}'
460
+
461
+ # Test file upload URL
462
+ curl -X POST http://localhost:8080/files/upload-url \
463
+ -H "Content-Type: application/json" \
464
+ -d '{"path":"test.txt","contentType":"text/plain"}'
465
+ ```
466
+
467
+ ---
468
+
469
+ ## Troubleshooting
470
+
471
+ ### "Auth service unavailable"
472
+
473
+ **Problem:** Your app can't reach the kernel.
474
+
475
+ **Solution:**
476
+ 1. Make sure `KERNEL_URL` is set correctly
477
+ 2. Check if the kernel service is running: `curl $KERNEL_URL/health`
478
+
479
+ ### "TOKEN_EXPIRED"
480
+
481
+ **Problem:** The Firebase token has expired.
482
+
483
+ **Solution:** Refresh the token in your frontend:
484
+ ```typescript
485
+ const token = await firebase.auth().currentUser.getIdToken(true);
486
+ ```
487
+
488
+ ### File upload fails
489
+
490
+ **Common causes:**
491
+ 1. **Wrong content type:** The `contentType` in your request must match the file
492
+ 2. **URL expired:** Signed URLs expire after 1 hour
493
+ 3. **Invalid path:** Paths can't start with `/` or contain `..`
494
+
495
+ ---
496
+
497
+ ## Summary
498
+
499
+ | What | How to use |
500
+ |------|------------|
501
+ | **Validate a user** | POST to `KERNEL_URL/auth/validate` |
502
+ | **Get upload URL** | POST to `KERNEL_URL/files/upload-url` |
503
+ | **Get download URL** | POST to `KERNEL_URL/files/download-url` |
504
+ | **List files** | POST to `KERNEL_URL/files/list` |
505
+ | **Delete file** | POST to `KERNEL_URL/files/delete` |
506
+ | **Move file** | POST to `KERNEL_URL/files/move` |
507
+ | **Get metadata** | POST to `KERNEL_URL/files/metadata` |
508
+ | **Publish event** | POST to `KERNEL_URL/events/publish` |
package/dist/index.js CHANGED
@@ -93,6 +93,23 @@ var gcpKernelResource = defineResource({
93
93
  // GCP Kernel - Serverless kernel using Cloud Run + Pub/Sub
94
94
  // =============================================================================
95
95
 
96
+ // Enable required APIs
97
+ const ${varName}FirestoreApi = new ProjectService(this, '${name}-firestore-api', {
98
+ service: 'firestore.googleapis.com',
99
+ disableOnDestroy: false,
100
+ });
101
+
102
+ // Create Firestore database (required for access control)
103
+ // Using FIRESTORE_NATIVE mode for document-based access control
104
+ const ${varName}FirestoreDb = new FirestoreDatabase(this, '${name}-firestore-db', {
105
+ project: '${projectId}',
106
+ name: '(default)',
107
+ locationId: '${location}',
108
+ type: 'FIRESTORE_NATIVE',
109
+ deleteProtectionState: 'DELETE_PROTECTION_DISABLED',
110
+ dependsOn: [${varName}FirestoreApi],
111
+ });
112
+
96
113
  // Service account for the GCP kernel
97
114
  const ${varName}Sa = new ServiceAccount(this, '${name}-sa', {
98
115
  accountId: '${name}-gcp-kernel',
@@ -113,6 +130,13 @@ new ProjectIamMember(this, '${name}-pubsub-iam', {
113
130
  member: \`serviceAccount:\${${varName}Sa.email}\`,
114
131
  });
115
132
 
133
+ // Grant Firestore/Datastore access for access control
134
+ new ProjectIamMember(this, '${name}-firestore-iam', {
135
+ project: '${projectId}',
136
+ role: 'roles/datastore.user',
137
+ member: \`serviceAccount:\${${varName}Sa.email}\`,
138
+ });
139
+
116
140
  // =============================================================================
117
141
  // Pub/Sub Topics for Events
118
142
  // =============================================================================
@@ -137,6 +161,7 @@ const ${varName}Service = new CloudRunV2Service(this, '${name}', {
137
161
  name: '${name}-gcp-kernel',
138
162
  location: '${location}',
139
163
  ingress: 'INGRESS_TRAFFIC_ALL',
164
+ deletionProtection: false,
140
165
 
141
166
  template: {
142
167
  serviceAccount: ${varName}Sa.email,
@@ -150,7 +175,7 @@ const ${varName}Service = new CloudRunV2Service(this, '${name}', {
150
175
  },
151
176
  },
152
177
  env: [
153
- { name: 'PORT', value: '8080' },
178
+ // Note: PORT is automatically set by Cloud Run, don't specify it
154
179
  { name: 'GCP_PROJECT_ID', value: '${projectId}' },
155
180
  { name: 'FIREBASE_PROJECT_ID', value: '${firebaseProjectId}' },
156
181
  { name: 'GCS_BUCKET', value: '${storageBucket}' },
@@ -175,6 +200,8 @@ new CloudRunV2ServiceIamMember(this, '${name}-public', {
175
200
  });`;
176
201
  return {
177
202
  imports: [
203
+ "import { ProjectService } from '@cdktf/provider-google/lib/project-service';",
204
+ "import { FirestoreDatabase } from '@cdktf/provider-google/lib/firestore-database';",
178
205
  "import { ServiceAccount } from '@cdktf/provider-google/lib/service-account';",
179
206
  "import { ProjectIamMember } from '@cdktf/provider-google/lib/project-iam-member';",
180
207
  "import { CloudRunV2Service } from '@cdktf/provider-google/lib/cloud-run-v2-service';",
@@ -218,7 +245,7 @@ new CloudRunV2ServiceIamMember(this, '${name}-public', {
218
245
  });
219
246
 
220
247
  // src/index.ts
221
- var VERSION = "0.1.0";
248
+ var VERSION = "0.1.2";
222
249
  var plugin = {
223
250
  name: "@stacksolo/plugin-gcp-kernel",
224
251
  version: VERSION,
@@ -232,7 +259,7 @@ var plugin = {
232
259
  http: 8080
233
260
  },
234
261
  env: {
235
- PORT: "8080",
262
+ // PORT is automatically set by Cloud Run - don't specify it
236
263
  GCP_PROJECT_ID: "",
237
264
  FIREBASE_PROJECT_ID: "",
238
265
  GCS_BUCKET: "",
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/resources/gcp-kernel.ts","../src/index.ts"],"sourcesContent":["import { defineResource, type ResourceConfig } from '@stacksolo/core';\n\nfunction toVariableName(name: string): string {\n return name.replace(/[^a-zA-Z0-9]/g, '_').replace(/^(\\d)/, '_$1');\n}\n\n/**\n * GCP Kernel Resource\n *\n * A fully GCP-native kernel implementation using:\n * - Cloud Run for HTTP endpoints (auth, files, events)\n * - Cloud Pub/Sub for event messaging (replaces NATS/JetStream)\n * - Cloud Storage for file operations\n * - Firebase Admin SDK for token validation\n *\n * This is the serverless alternative to the K8s kernel which uses embedded NATS.\n */\nexport const gcpKernelResource = defineResource({\n id: 'gcp-kernel:gcp_kernel',\n provider: 'gcp-kernel',\n name: 'GCP Kernel',\n description: 'Serverless kernel using Cloud Run + Pub/Sub (alternative to NATS-based K8s kernel)',\n icon: 'cpu',\n\n configSchema: {\n type: 'object',\n properties: {\n name: {\n type: 'string',\n title: 'Name',\n description: 'Resource name for references (@gcp-kernel/<name>)',\n default: 'kernel',\n },\n location: {\n type: 'string',\n title: 'Region',\n description: 'GCP region (defaults to project region)',\n },\n cpu: {\n type: 'string',\n title: 'CPU',\n description: 'CPU allocation',\n default: '1',\n enum: ['1', '2', '4'],\n },\n memory: {\n type: 'string',\n title: 'Memory',\n description: 'Memory allocation',\n default: '512Mi',\n enum: ['256Mi', '512Mi', '1Gi', '2Gi'],\n },\n minInstances: {\n type: 'number',\n title: 'Min Instances',\n description: 'Minimum instances (0 for scale-to-zero)',\n default: 0,\n },\n maxInstances: {\n type: 'number',\n title: 'Max Instances',\n description: 'Maximum instances',\n default: 10,\n },\n firebaseProjectId: {\n type: 'string',\n title: 'Firebase Project ID',\n description: 'Firebase project for auth token validation',\n },\n storageBucket: {\n type: 'string',\n title: 'Storage Bucket',\n description: 'GCS bucket for file uploads',\n },\n eventRetentionDays: {\n type: 'number',\n title: 'Event Retention Days',\n description: 'How long to retain events in Pub/Sub',\n default: 7,\n },\n },\n required: ['firebaseProjectId', 'storageBucket'],\n },\n\n defaultConfig: {\n name: 'kernel',\n cpu: '1',\n memory: '512Mi',\n minInstances: 0,\n maxInstances: 10,\n eventRetentionDays: 7,\n },\n\n generate: (config: ResourceConfig) => {\n const varName = toVariableName(config.name as string);\n const name = (config.name as string) || 'kernel';\n const location = (config.location as string) || '${var.region}';\n const cpu = (config.cpu as string) || '1';\n const memory = (config.memory as string) || '512Mi';\n const minInstances = (config.minInstances as number) ?? 0;\n const maxInstances = (config.maxInstances as number) ?? 10;\n const firebaseProjectId = config.firebaseProjectId as string;\n const storageBucket = config.storageBucket as string;\n const eventRetentionDays = (config.eventRetentionDays as number) ?? 7;\n const projectId = (config.projectId as string) || '${var.project_id}';\n\n // Convert retention days to seconds\n const messageRetentionSeconds = eventRetentionDays * 24 * 60 * 60;\n\n const code = `// =============================================================================\n// GCP Kernel - Serverless kernel using Cloud Run + Pub/Sub\n// =============================================================================\n\n// Service account for the GCP kernel\nconst ${varName}Sa = new ServiceAccount(this, '${name}-sa', {\n accountId: '${name}-gcp-kernel',\n displayName: 'GCP Kernel Service Account',\n});\n\n// Grant storage access for files service\nnew ProjectIamMember(this, '${name}-storage-iam', {\n project: '${projectId}',\n role: 'roles/storage.objectAdmin',\n member: \\`serviceAccount:\\${${varName}Sa.email}\\`,\n});\n\n// Grant Pub/Sub access for events service\nnew ProjectIamMember(this, '${name}-pubsub-iam', {\n project: '${projectId}',\n role: 'roles/pubsub.editor',\n member: \\`serviceAccount:\\${${varName}Sa.email}\\`,\n});\n\n// =============================================================================\n// Pub/Sub Topics for Events\n// =============================================================================\n\n// Main events topic\nconst ${varName}EventsTopic = new PubsubTopic(this, '${name}-events-topic', {\n name: 'stacksolo-${name}-events',\n messageRetentionDuration: '${messageRetentionSeconds}s',\n});\n\n// Dead letter topic for failed message delivery\nconst ${varName}DlqTopic = new PubsubTopic(this, '${name}-dlq-topic', {\n name: 'stacksolo-${name}-events-dlq',\n messageRetentionDuration: '${messageRetentionSeconds * 2}s',\n});\n\n// =============================================================================\n// Cloud Run Service\n// =============================================================================\n\nconst ${varName}Service = new CloudRunV2Service(this, '${name}', {\n name: '${name}-gcp-kernel',\n location: '${location}',\n ingress: 'INGRESS_TRAFFIC_ALL',\n\n template: {\n serviceAccount: ${varName}Sa.email,\n containers: [{\n image: 'gcr.io/${projectId}/stacksolo-gcp-kernel:latest',\n ports: { containerPort: 8080 },\n resources: {\n limits: {\n cpu: '${cpu}',\n memory: '${memory}',\n },\n },\n env: [\n { name: 'PORT', value: '8080' },\n { name: 'GCP_PROJECT_ID', value: '${projectId}' },\n { name: 'FIREBASE_PROJECT_ID', value: '${firebaseProjectId}' },\n { name: 'GCS_BUCKET', value: '${storageBucket}' },\n { name: 'PUBSUB_EVENTS_TOPIC', value: \\`\\${${varName}EventsTopic.name}\\` },\n { name: 'PUBSUB_DLQ_TOPIC', value: \\`\\${${varName}DlqTopic.name}\\` },\n { name: 'KERNEL_TYPE', value: 'gcp' },\n ],\n }],\n scaling: {\n minInstanceCount: ${minInstances},\n maxInstanceCount: ${maxInstances},\n },\n },\n});\n\n// Allow unauthenticated access (kernel validates tokens internally)\nnew CloudRunV2ServiceIamMember(this, '${name}-public', {\n name: ${varName}Service.name,\n location: ${varName}Service.location,\n role: 'roles/run.invoker',\n member: 'allUsers',\n});`;\n\n return {\n imports: [\n \"import { ServiceAccount } from '@cdktf/provider-google/lib/service-account';\",\n \"import { ProjectIamMember } from '@cdktf/provider-google/lib/project-iam-member';\",\n \"import { CloudRunV2Service } from '@cdktf/provider-google/lib/cloud-run-v2-service';\",\n \"import { CloudRunV2ServiceIamMember } from '@cdktf/provider-google/lib/cloud-run-v2-service-iam-member';\",\n \"import { PubsubTopic } from '@cdktf/provider-google/lib/pubsub-topic';\",\n ],\n code,\n outputs: [\n `export const ${varName}Url = ${varName}Service.uri;`,\n `export const ${varName}AuthUrl = \\`\\${${varName}Service.uri}/auth/validate\\`;`,\n `export const ${varName}FilesUrl = \\`\\${${varName}Service.uri}/files\\`;`,\n `export const ${varName}EventsUrl = \\`\\${${varName}Service.uri}/events\\`;`,\n `export const ${varName}EventsTopic = ${varName}EventsTopic.name;`,\n ],\n };\n },\n\n estimateCost: (config: ResourceConfig) => {\n const minInstances = (config.minInstances as number) ?? 0;\n const cpu = parseFloat((config.cpu as string) || '1');\n const memory = parseFloat(((config.memory as string) || '512Mi').replace('Mi', '')) / 1024 || 0.5;\n\n // Cloud Run pricing\n let cloudRunCost = 0;\n if (minInstances > 0) {\n // Always-on instances\n const hoursPerMonth = 730;\n const cpuCost = minInstances * cpu * hoursPerMonth * 0.00002400 * 3600;\n const memoryCost = minInstances * memory * hoursPerMonth * 0.00000250 * 3600;\n cloudRunCost = cpuCost + memoryCost;\n } else {\n // Pay-per-use estimate (assuming 100k requests/month @ 200ms avg)\n const estimatedSeconds = 100000 * 0.2;\n cloudRunCost = estimatedSeconds * cpu * 0.00002400 + estimatedSeconds * memory * 0.00000250;\n }\n\n // Pub/Sub pricing (~$0.40/million messages, assume 100k messages/month)\n const pubsubCost = 0.04;\n\n return {\n monthly: Math.round(cloudRunCost + pubsubCost),\n currency: 'USD',\n breakdown: [\n { item: `Cloud Run (${minInstances > 0 ? 'always-on' : 'scale-to-zero'})`, amount: Math.round(cloudRunCost) },\n { item: 'Pub/Sub (~100k messages)', amount: Math.round(pubsubCost * 100) / 100 },\n ],\n };\n },\n});\n","/**\n * StackSolo GCP Kernel Plugin\n *\n * GCP-native kernel implementation using Cloud Run + Pub/Sub.\n * This is the serverless alternative to the NATS-based kernel plugin.\n *\n * Endpoints:\n * - GET /health - Health check\n * - POST /auth/validate - Validate Firebase token\n * - POST /files/* - File operations (upload-url, download-url, list, delete, move, metadata)\n * - POST /events/publish - Publish event to Pub/Sub\n * - POST /events/subscribe - Register HTTP push subscription\n */\n\nimport type { Plugin } from '@stacksolo/core';\nimport { gcpKernelResource } from './resources/index';\n\n/** Plugin version - must match package.json */\nconst VERSION = '0.1.0';\n\nexport const plugin: Plugin = {\n name: '@stacksolo/plugin-gcp-kernel',\n version: VERSION,\n resources: [gcpKernelResource],\n services: [\n {\n name: 'gcp-kernel',\n image: `ghcr.io/monkeybarrels/stacksolo-gcp-kernel:${VERSION}`,\n sourcePath: './service',\n ports: {\n http: 8080,\n },\n env: {\n PORT: '8080',\n GCP_PROJECT_ID: '',\n FIREBASE_PROJECT_ID: '',\n GCS_BUCKET: '',\n PUBSUB_EVENTS_TOPIC: '',\n },\n resources: {\n cpu: '1',\n memory: '512Mi',\n },\n },\n ],\n};\n\nexport default plugin;\n\n// Re-export types\nexport type { GcpKernelConfig, GcpKernelOutputs } from './types';\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/resources/gcp-kernel.ts","../src/index.ts"],"sourcesContent":["import { defineResource, type ResourceConfig } from '@stacksolo/core';\n\nfunction toVariableName(name: string): string {\n return name.replace(/[^a-zA-Z0-9]/g, '_').replace(/^(\\d)/, '_$1');\n}\n\n/**\n * GCP Kernel Resource\n *\n * A fully GCP-native kernel implementation using:\n * - Cloud Run for HTTP endpoints (auth, files, events)\n * - Cloud Pub/Sub for event messaging (replaces NATS/JetStream)\n * - Cloud Storage for file operations\n * - Firebase Admin SDK for token validation\n *\n * This is the serverless alternative to the K8s kernel which uses embedded NATS.\n */\nexport const gcpKernelResource = defineResource({\n id: 'gcp-kernel:gcp_kernel',\n provider: 'gcp-kernel',\n name: 'GCP Kernel',\n description: 'Serverless kernel using Cloud Run + Pub/Sub (alternative to NATS-based K8s kernel)',\n icon: 'cpu',\n\n configSchema: {\n type: 'object',\n properties: {\n name: {\n type: 'string',\n title: 'Name',\n description: 'Resource name for references (@gcp-kernel/<name>)',\n default: 'kernel',\n },\n location: {\n type: 'string',\n title: 'Region',\n description: 'GCP region (defaults to project region)',\n },\n cpu: {\n type: 'string',\n title: 'CPU',\n description: 'CPU allocation',\n default: '1',\n enum: ['1', '2', '4'],\n },\n memory: {\n type: 'string',\n title: 'Memory',\n description: 'Memory allocation',\n default: '512Mi',\n enum: ['256Mi', '512Mi', '1Gi', '2Gi'],\n },\n minInstances: {\n type: 'number',\n title: 'Min Instances',\n description: 'Minimum instances (0 for scale-to-zero)',\n default: 0,\n },\n maxInstances: {\n type: 'number',\n title: 'Max Instances',\n description: 'Maximum instances',\n default: 10,\n },\n firebaseProjectId: {\n type: 'string',\n title: 'Firebase Project ID',\n description: 'Firebase project for auth token validation',\n },\n storageBucket: {\n type: 'string',\n title: 'Storage Bucket',\n description: 'GCS bucket for file uploads',\n },\n eventRetentionDays: {\n type: 'number',\n title: 'Event Retention Days',\n description: 'How long to retain events in Pub/Sub',\n default: 7,\n },\n },\n required: ['firebaseProjectId', 'storageBucket'],\n },\n\n defaultConfig: {\n name: 'kernel',\n cpu: '1',\n memory: '512Mi',\n minInstances: 0,\n maxInstances: 10,\n eventRetentionDays: 7,\n },\n\n generate: (config: ResourceConfig) => {\n const varName = toVariableName(config.name as string);\n const name = (config.name as string) || 'kernel';\n const location = (config.location as string) || '${var.region}';\n const cpu = (config.cpu as string) || '1';\n const memory = (config.memory as string) || '512Mi';\n const minInstances = (config.minInstances as number) ?? 0;\n const maxInstances = (config.maxInstances as number) ?? 10;\n const firebaseProjectId = config.firebaseProjectId as string;\n const storageBucket = config.storageBucket as string;\n const eventRetentionDays = (config.eventRetentionDays as number) ?? 7;\n const projectId = (config.projectId as string) || '${var.project_id}';\n\n // Convert retention days to seconds\n const messageRetentionSeconds = eventRetentionDays * 24 * 60 * 60;\n\n const code = `// =============================================================================\n// GCP Kernel - Serverless kernel using Cloud Run + Pub/Sub\n// =============================================================================\n\n// Enable required APIs\nconst ${varName}FirestoreApi = new ProjectService(this, '${name}-firestore-api', {\n service: 'firestore.googleapis.com',\n disableOnDestroy: false,\n});\n\n// Create Firestore database (required for access control)\n// Using FIRESTORE_NATIVE mode for document-based access control\nconst ${varName}FirestoreDb = new FirestoreDatabase(this, '${name}-firestore-db', {\n project: '${projectId}',\n name: '(default)',\n locationId: '${location}',\n type: 'FIRESTORE_NATIVE',\n deleteProtectionState: 'DELETE_PROTECTION_DISABLED',\n dependsOn: [${varName}FirestoreApi],\n});\n\n// Service account for the GCP kernel\nconst ${varName}Sa = new ServiceAccount(this, '${name}-sa', {\n accountId: '${name}-gcp-kernel',\n displayName: 'GCP Kernel Service Account',\n});\n\n// Grant storage access for files service\nnew ProjectIamMember(this, '${name}-storage-iam', {\n project: '${projectId}',\n role: 'roles/storage.objectAdmin',\n member: \\`serviceAccount:\\${${varName}Sa.email}\\`,\n});\n\n// Grant Pub/Sub access for events service\nnew ProjectIamMember(this, '${name}-pubsub-iam', {\n project: '${projectId}',\n role: 'roles/pubsub.editor',\n member: \\`serviceAccount:\\${${varName}Sa.email}\\`,\n});\n\n// Grant Firestore/Datastore access for access control\nnew ProjectIamMember(this, '${name}-firestore-iam', {\n project: '${projectId}',\n role: 'roles/datastore.user',\n member: \\`serviceAccount:\\${${varName}Sa.email}\\`,\n});\n\n// =============================================================================\n// Pub/Sub Topics for Events\n// =============================================================================\n\n// Main events topic\nconst ${varName}EventsTopic = new PubsubTopic(this, '${name}-events-topic', {\n name: 'stacksolo-${name}-events',\n messageRetentionDuration: '${messageRetentionSeconds}s',\n});\n\n// Dead letter topic for failed message delivery\nconst ${varName}DlqTopic = new PubsubTopic(this, '${name}-dlq-topic', {\n name: 'stacksolo-${name}-events-dlq',\n messageRetentionDuration: '${messageRetentionSeconds * 2}s',\n});\n\n// =============================================================================\n// Cloud Run Service\n// =============================================================================\n\nconst ${varName}Service = new CloudRunV2Service(this, '${name}', {\n name: '${name}-gcp-kernel',\n location: '${location}',\n ingress: 'INGRESS_TRAFFIC_ALL',\n deletionProtection: false,\n\n template: {\n serviceAccount: ${varName}Sa.email,\n containers: [{\n image: 'gcr.io/${projectId}/stacksolo-gcp-kernel:latest',\n ports: { containerPort: 8080 },\n resources: {\n limits: {\n cpu: '${cpu}',\n memory: '${memory}',\n },\n },\n env: [\n // Note: PORT is automatically set by Cloud Run, don't specify it\n { name: 'GCP_PROJECT_ID', value: '${projectId}' },\n { name: 'FIREBASE_PROJECT_ID', value: '${firebaseProjectId}' },\n { name: 'GCS_BUCKET', value: '${storageBucket}' },\n { name: 'PUBSUB_EVENTS_TOPIC', value: \\`\\${${varName}EventsTopic.name}\\` },\n { name: 'PUBSUB_DLQ_TOPIC', value: \\`\\${${varName}DlqTopic.name}\\` },\n { name: 'KERNEL_TYPE', value: 'gcp' },\n ],\n }],\n scaling: {\n minInstanceCount: ${minInstances},\n maxInstanceCount: ${maxInstances},\n },\n },\n});\n\n// Allow unauthenticated access (kernel validates tokens internally)\nnew CloudRunV2ServiceIamMember(this, '${name}-public', {\n name: ${varName}Service.name,\n location: ${varName}Service.location,\n role: 'roles/run.invoker',\n member: 'allUsers',\n});`;\n\n return {\n imports: [\n \"import { ProjectService } from '@cdktf/provider-google/lib/project-service';\",\n \"import { FirestoreDatabase } from '@cdktf/provider-google/lib/firestore-database';\",\n \"import { ServiceAccount } from '@cdktf/provider-google/lib/service-account';\",\n \"import { ProjectIamMember } from '@cdktf/provider-google/lib/project-iam-member';\",\n \"import { CloudRunV2Service } from '@cdktf/provider-google/lib/cloud-run-v2-service';\",\n \"import { CloudRunV2ServiceIamMember } from '@cdktf/provider-google/lib/cloud-run-v2-service-iam-member';\",\n \"import { PubsubTopic } from '@cdktf/provider-google/lib/pubsub-topic';\",\n ],\n code,\n outputs: [\n `export const ${varName}Url = ${varName}Service.uri;`,\n `export const ${varName}AuthUrl = \\`\\${${varName}Service.uri}/auth/validate\\`;`,\n `export const ${varName}FilesUrl = \\`\\${${varName}Service.uri}/files\\`;`,\n `export const ${varName}EventsUrl = \\`\\${${varName}Service.uri}/events\\`;`,\n `export const ${varName}EventsTopic = ${varName}EventsTopic.name;`,\n ],\n };\n },\n\n estimateCost: (config: ResourceConfig) => {\n const minInstances = (config.minInstances as number) ?? 0;\n const cpu = parseFloat((config.cpu as string) || '1');\n const memory = parseFloat(((config.memory as string) || '512Mi').replace('Mi', '')) / 1024 || 0.5;\n\n // Cloud Run pricing\n let cloudRunCost = 0;\n if (minInstances > 0) {\n // Always-on instances\n const hoursPerMonth = 730;\n const cpuCost = minInstances * cpu * hoursPerMonth * 0.00002400 * 3600;\n const memoryCost = minInstances * memory * hoursPerMonth * 0.00000250 * 3600;\n cloudRunCost = cpuCost + memoryCost;\n } else {\n // Pay-per-use estimate (assuming 100k requests/month @ 200ms avg)\n const estimatedSeconds = 100000 * 0.2;\n cloudRunCost = estimatedSeconds * cpu * 0.00002400 + estimatedSeconds * memory * 0.00000250;\n }\n\n // Pub/Sub pricing (~$0.40/million messages, assume 100k messages/month)\n const pubsubCost = 0.04;\n\n return {\n monthly: Math.round(cloudRunCost + pubsubCost),\n currency: 'USD',\n breakdown: [\n { item: `Cloud Run (${minInstances > 0 ? 'always-on' : 'scale-to-zero'})`, amount: Math.round(cloudRunCost) },\n { item: 'Pub/Sub (~100k messages)', amount: Math.round(pubsubCost * 100) / 100 },\n ],\n };\n },\n});\n","/**\n * StackSolo GCP Kernel Plugin\n *\n * GCP-native kernel implementation using Cloud Run + Pub/Sub.\n * This is the serverless alternative to the NATS-based kernel plugin.\n *\n * Endpoints:\n * - GET /health - Health check\n * - POST /auth/validate - Validate Firebase token\n * - POST /files/* - File operations (upload-url, download-url, list, delete, move, metadata)\n * - POST /events/publish - Publish event to Pub/Sub\n * - POST /events/subscribe - Register HTTP push subscription\n */\n\nimport type { Plugin } from '@stacksolo/core';\nimport { gcpKernelResource } from './resources/index';\n\n/** Plugin version - must match package.json */\nconst VERSION = '0.1.2';\n\nexport const plugin: Plugin = {\n name: '@stacksolo/plugin-gcp-kernel',\n version: VERSION,\n resources: [gcpKernelResource],\n services: [\n {\n name: 'gcp-kernel',\n image: `ghcr.io/monkeybarrels/stacksolo-gcp-kernel:${VERSION}`,\n sourcePath: './service',\n ports: {\n http: 8080,\n },\n env: {\n // PORT is automatically set by Cloud Run - don't specify it\n GCP_PROJECT_ID: '',\n FIREBASE_PROJECT_ID: '',\n GCS_BUCKET: '',\n PUBSUB_EVENTS_TOPIC: '',\n },\n resources: {\n cpu: '1',\n memory: '512Mi',\n },\n },\n ],\n};\n\nexport default plugin;\n\n// Re-export types\nexport type { GcpKernelConfig, GcpKernelOutputs } from './types';\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stacksolo/plugin-gcp-kernel",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "GCP-native kernel plugin for StackSolo (Cloud Run + Pub/Sub)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,4 +1,4 @@
1
- FROM node:20-alpine
1
+ FROM --platform=linux/amd64 node:20-alpine
2
2
 
3
3
  # Create app directory
4
4
  WORKDIR /app
@@ -6,6 +6,7 @@
6
6
  * - Cloud Pub/Sub for event messaging (replaces NATS/JetStream)
7
7
  * - Cloud Storage for file operations
8
8
  * - Firebase Admin SDK for token validation
9
+ * - Firestore for access control
9
10
  *
10
11
  * Endpoints:
11
12
  * - GET /health - Health check
@@ -20,6 +21,11 @@
20
21
  * - POST /events/subscribe - Register HTTP push subscription
21
22
  * - POST /events/unsubscribe - Remove subscription
22
23
  * - GET /events/subscriptions - List subscriptions
24
+ * - POST /access/grant - Grant access to a resource
25
+ * - POST /access/revoke - Revoke access from a resource
26
+ * - POST /access/check - Check if member has access
27
+ * - GET /access/list - List members with access
28
+ * - GET /access/resources - List all protected resources
23
29
  */
24
30
 
25
31
  import express from 'express';
@@ -30,6 +36,7 @@ import { healthRouter } from './routes/health.js';
30
36
  import { authRouter } from './routes/auth.js';
31
37
  import { filesRouter } from './routes/files.js';
32
38
  import { eventsRouter } from './routes/events.js';
39
+ import { accessRouter } from './routes/access.js';
33
40
 
34
41
  const app = express();
35
42
  const PORT = parseInt(process.env.PORT || '8080', 10);
@@ -51,6 +58,7 @@ app.use('/health', healthRouter);
51
58
  app.use('/auth', authRouter);
52
59
  app.use('/files', filesRouter);
53
60
  app.use('/events', eventsRouter);
61
+ app.use('/access', accessRouter);
54
62
 
55
63
  // Root endpoint
56
64
  app.get('/', (_req, res) => {
@@ -63,6 +71,7 @@ app.get('/', (_req, res) => {
63
71
  auth: '/auth/validate',
64
72
  files: '/files/*',
65
73
  events: '/events/*',
74
+ access: '/access/*',
66
75
  },
67
76
  });
68
77
  });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Access Control Routes
3
+ *
4
+ * Endpoints for managing dynamic access control via Firestore.
5
+ *
6
+ * POST /access/grant - Grant access to a resource
7
+ * POST /access/revoke - Revoke access from a resource
8
+ * POST /access/check - Check if member has access
9
+ * GET /access/list - List members with access to a resource
10
+ * GET /access/resources - List all protected resources
11
+ */
12
+
13
+ import { Router } from 'express';
14
+ import {
15
+ grantAccess,
16
+ revokeAccess,
17
+ checkAccess,
18
+ listAccess,
19
+ listResources,
20
+ } from '../services/access.js';
21
+
22
+ export const accessRouter = Router();
23
+
24
+ /**
25
+ * Grant access to a resource
26
+ *
27
+ * POST /access/grant
28
+ * Body: { resource, member, permissions, grantedBy }
29
+ */
30
+ accessRouter.post('/grant', async (req, res) => {
31
+ const { resource, member, permissions, grantedBy } = req.body;
32
+
33
+ if (!resource || !member || !permissions || !grantedBy) {
34
+ return res.status(400).json({
35
+ error: 'Missing required fields: resource, member, permissions, grantedBy',
36
+ });
37
+ }
38
+
39
+ if (!Array.isArray(permissions)) {
40
+ return res.status(400).json({
41
+ error: 'permissions must be an array',
42
+ });
43
+ }
44
+
45
+ const result = await grantAccess({ resource, member, permissions, grantedBy });
46
+
47
+ if (result.success) {
48
+ return res.json({ granted: true, resource, member, permissions });
49
+ }
50
+
51
+ return res.status(500).json({ error: result.error });
52
+ });
53
+
54
+ /**
55
+ * Revoke access from a resource
56
+ *
57
+ * POST /access/revoke
58
+ * Body: { resource, member, revokedBy }
59
+ */
60
+ accessRouter.post('/revoke', async (req, res) => {
61
+ const { resource, member, revokedBy } = req.body;
62
+
63
+ if (!resource || !member || !revokedBy) {
64
+ return res.status(400).json({
65
+ error: 'Missing required fields: resource, member, revokedBy',
66
+ });
67
+ }
68
+
69
+ const result = await revokeAccess({ resource, member, revokedBy });
70
+
71
+ if (result.success) {
72
+ return res.json({ revoked: true, resource, member });
73
+ }
74
+
75
+ if (result.error === 'Access grant not found') {
76
+ return res.status(404).json({ error: result.error });
77
+ }
78
+
79
+ return res.status(500).json({ error: result.error });
80
+ });
81
+
82
+ /**
83
+ * Check if member has access to a resource
84
+ *
85
+ * POST /access/check
86
+ * Body: { resource, member, permission? }
87
+ */
88
+ accessRouter.post('/check', async (req, res) => {
89
+ const { resource, member, permission } = req.body;
90
+
91
+ if (!resource || !member) {
92
+ return res.status(400).json({
93
+ error: 'Missing required fields: resource, member',
94
+ });
95
+ }
96
+
97
+ const result = await checkAccess({ resource, member, permission });
98
+
99
+ // Transform response to match runtime expectation (hasAccess instead of allowed)
100
+ return res.json({
101
+ hasAccess: result.allowed,
102
+ permissions: result.permissions || [],
103
+ reason: result.reason,
104
+ });
105
+ });
106
+
107
+ /**
108
+ * List members with access to a resource
109
+ *
110
+ * GET /access/list?resource=xxx
111
+ */
112
+ accessRouter.get('/list', async (req, res) => {
113
+ const { resource } = req.query;
114
+
115
+ if (!resource || typeof resource !== 'string') {
116
+ return res.status(400).json({
117
+ error: 'Missing required query param: resource',
118
+ });
119
+ }
120
+
121
+ const result = await listAccess({ resource });
122
+
123
+ if (result.error) {
124
+ return res.status(500).json({ error: result.error });
125
+ }
126
+
127
+ return res.json({ resource, members: result.members });
128
+ });
129
+
130
+ /**
131
+ * List all protected resources
132
+ *
133
+ * GET /access/resources
134
+ */
135
+ accessRouter.get('/resources', async (_req, res) => {
136
+ const result = await listResources();
137
+
138
+ if (result.error) {
139
+ return res.status(500).json({ error: result.error });
140
+ }
141
+
142
+ return res.json({ resources: result.resources });
143
+ });
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Auth Routes
3
3
  *
4
+ * GET /auth/whoami - Get current user from IAP headers
4
5
  * POST /auth/validate - Validate Firebase ID token
5
6
  */
6
7
 
@@ -9,6 +10,44 @@ import { validateToken } from '../services/firebase.js';
9
10
 
10
11
  export const authRouter = Router();
11
12
 
13
+ /**
14
+ * Get current user identity from IAP headers
15
+ *
16
+ * GET /auth/whoami
17
+ *
18
+ * Returns the authenticated user's email and ID from IAP headers.
19
+ * This endpoint is useful for SPAs that need to know who is logged in.
20
+ */
21
+ authRouter.get('/whoami', (req: Request, res: Response) => {
22
+ // IAP sets these headers after authentication
23
+ const iapEmail = req.headers['x-goog-authenticated-user-email'] as string | undefined;
24
+ const iapId = req.headers['x-goog-authenticated-user-id'] as string | undefined;
25
+
26
+ if (!iapEmail) {
27
+ res.status(401).json({
28
+ authenticated: false,
29
+ error: 'No IAP authentication detected',
30
+ hint: 'This endpoint requires requests to pass through Identity-Aware Proxy',
31
+ });
32
+ return;
33
+ }
34
+
35
+ // IAP email format: "accounts.google.com:user@example.com"
36
+ const email = iapEmail.replace('accounts.google.com:', '');
37
+ const id = iapId?.replace('accounts.google.com:', '');
38
+
39
+ res.json({
40
+ authenticated: true,
41
+ email,
42
+ id,
43
+ // Include raw headers for debugging
44
+ raw: {
45
+ email: iapEmail,
46
+ id: iapId,
47
+ },
48
+ });
49
+ });
50
+
12
51
  interface ValidateRequest {
13
52
  token: string;
14
53
  }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Access Control Service
3
+ *
4
+ * Manages dynamic access control via Firestore.
5
+ * Uses the kernel_access collection to store access rules.
6
+ *
7
+ * Collection structure:
8
+ * kernel_access/{resource}/members/{email} => { granted, grantedBy, permissions }
9
+ */
10
+
11
+ import admin from 'firebase-admin';
12
+
13
+ const DEFAULT_COLLECTION = 'kernel_access';
14
+
15
+ interface AccessGrant {
16
+ granted: admin.firestore.Timestamp;
17
+ grantedBy: string;
18
+ permissions: string[];
19
+ }
20
+
21
+ interface AccessEntry {
22
+ member: string;
23
+ granted: Date;
24
+ grantedBy: string;
25
+ permissions: string[];
26
+ }
27
+
28
+ interface AuditEntry {
29
+ action: 'grant' | 'revoke';
30
+ resource: string;
31
+ member: string;
32
+ permissions?: string[];
33
+ performedBy: string;
34
+ timestamp: admin.firestore.Timestamp;
35
+ }
36
+
37
+ function getFirestore(): admin.firestore.Firestore {
38
+ return admin.firestore();
39
+ }
40
+
41
+ function getCollection(): string {
42
+ return process.env.ACCESS_COLLECTION || DEFAULT_COLLECTION;
43
+ }
44
+
45
+ /**
46
+ * Grant access to a resource for a member
47
+ */
48
+ export async function grantAccess(params: {
49
+ resource: string;
50
+ member: string;
51
+ permissions: string[];
52
+ grantedBy: string;
53
+ }): Promise<{ success: boolean; error?: string }> {
54
+ const { resource, member, permissions, grantedBy } = params;
55
+ const db = getFirestore();
56
+ const collection = getCollection();
57
+
58
+ try {
59
+ const docRef = db
60
+ .collection(collection)
61
+ .doc(resource)
62
+ .collection('members')
63
+ .doc(member);
64
+
65
+ const grant: AccessGrant = {
66
+ granted: admin.firestore.Timestamp.now(),
67
+ grantedBy,
68
+ permissions,
69
+ };
70
+
71
+ await docRef.set(grant);
72
+
73
+ // Audit log
74
+ await db.collection(`${collection}_audit`).add({
75
+ action: 'grant',
76
+ resource,
77
+ member,
78
+ permissions,
79
+ performedBy: grantedBy,
80
+ timestamp: admin.firestore.Timestamp.now(),
81
+ } as AuditEntry);
82
+
83
+ return { success: true };
84
+ } catch (error) {
85
+ const message = error instanceof Error ? error.message : 'Failed to grant access';
86
+ return { success: false, error: message };
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Revoke access from a member for a resource
92
+ */
93
+ export async function revokeAccess(params: {
94
+ resource: string;
95
+ member: string;
96
+ revokedBy: string;
97
+ }): Promise<{ success: boolean; error?: string }> {
98
+ const { resource, member, revokedBy } = params;
99
+ const db = getFirestore();
100
+ const collection = getCollection();
101
+
102
+ try {
103
+ const docRef = db
104
+ .collection(collection)
105
+ .doc(resource)
106
+ .collection('members')
107
+ .doc(member);
108
+
109
+ const doc = await docRef.get();
110
+ if (!doc.exists) {
111
+ return { success: false, error: 'Access grant not found' };
112
+ }
113
+
114
+ await docRef.delete();
115
+
116
+ // Audit log
117
+ await db.collection(`${collection}_audit`).add({
118
+ action: 'revoke',
119
+ resource,
120
+ member,
121
+ performedBy: revokedBy,
122
+ timestamp: admin.firestore.Timestamp.now(),
123
+ } as AuditEntry);
124
+
125
+ return { success: true };
126
+ } catch (error) {
127
+ const message = error instanceof Error ? error.message : 'Failed to revoke access';
128
+ return { success: false, error: message };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Check if a member has access to a resource
134
+ */
135
+ export async function checkAccess(params: {
136
+ resource: string;
137
+ member: string;
138
+ permission?: string;
139
+ }): Promise<{ allowed: boolean; reason?: string; permissions?: string[] }> {
140
+ const { resource, member, permission } = params;
141
+ const db = getFirestore();
142
+ const collection = getCollection();
143
+
144
+ try {
145
+ const docRef = db
146
+ .collection(collection)
147
+ .doc(resource)
148
+ .collection('members')
149
+ .doc(member);
150
+
151
+ const doc = await docRef.get();
152
+
153
+ if (!doc.exists) {
154
+ return { allowed: false, reason: 'no_grant' };
155
+ }
156
+
157
+ const data = doc.data() as AccessGrant;
158
+
159
+ // If specific permission requested, check it
160
+ if (permission && !data.permissions.includes(permission)) {
161
+ return {
162
+ allowed: false,
163
+ reason: 'permission_denied',
164
+ permissions: data.permissions,
165
+ };
166
+ }
167
+
168
+ return {
169
+ allowed: true,
170
+ reason: 'explicit_grant',
171
+ permissions: data.permissions,
172
+ };
173
+ } catch (error) {
174
+ const message = error instanceof Error ? error.message : 'Failed to check access';
175
+ return { allowed: false, reason: message };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * List all members with access to a resource
181
+ */
182
+ export async function listAccess(params: {
183
+ resource: string;
184
+ }): Promise<{ members: AccessEntry[]; error?: string }> {
185
+ const { resource } = params;
186
+ const db = getFirestore();
187
+ const collection = getCollection();
188
+
189
+ try {
190
+ const snapshot = await db
191
+ .collection(collection)
192
+ .doc(resource)
193
+ .collection('members')
194
+ .get();
195
+
196
+ const members: AccessEntry[] = [];
197
+
198
+ snapshot.forEach((doc) => {
199
+ const data = doc.data() as AccessGrant;
200
+ members.push({
201
+ member: doc.id,
202
+ granted: data.granted.toDate(),
203
+ grantedBy: data.grantedBy,
204
+ permissions: data.permissions,
205
+ });
206
+ });
207
+
208
+ return { members };
209
+ } catch (error) {
210
+ const message = error instanceof Error ? error.message : 'Failed to list access';
211
+ return { members: [], error: message };
212
+ }
213
+ }
214
+
215
+ /**
216
+ * List all protected resources
217
+ */
218
+ export async function listResources(): Promise<{ resources: string[]; error?: string }> {
219
+ const db = getFirestore();
220
+ const collection = getCollection();
221
+
222
+ try {
223
+ const snapshot = await db.collection(collection).get();
224
+
225
+ const resources: string[] = [];
226
+ snapshot.forEach((doc) => {
227
+ resources.push(doc.id);
228
+ });
229
+
230
+ return { resources };
231
+ } catch (error) {
232
+ const message = error instanceof Error ? error.message : 'Failed to list resources';
233
+ return { resources: [], error: message };
234
+ }
235
+ }