thevoidforge 21.0.11 → 21.0.13
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/dist/.claude/commands/ai.md +69 -0
- package/dist/.claude/commands/architect.md +121 -0
- package/dist/.claude/commands/assemble.md +201 -0
- package/dist/.claude/commands/assess.md +75 -0
- package/dist/.claude/commands/blueprint.md +135 -0
- package/dist/.claude/commands/build.md +116 -0
- package/dist/.claude/commands/campaign.md +201 -0
- package/dist/.claude/commands/cultivation.md +166 -0
- package/dist/.claude/commands/current.md +128 -0
- package/dist/.claude/commands/dangerroom.md +74 -0
- package/dist/.claude/commands/debrief.md +178 -0
- package/dist/.claude/commands/deploy.md +99 -0
- package/dist/.claude/commands/devops.md +143 -0
- package/dist/.claude/commands/gauntlet.md +140 -0
- package/dist/.claude/commands/git.md +104 -0
- package/dist/.claude/commands/grow.md +146 -0
- package/dist/.claude/commands/imagine.md +126 -0
- package/dist/.claude/commands/portfolio.md +50 -0
- package/dist/.claude/commands/prd.md +113 -0
- package/dist/.claude/commands/qa.md +107 -0
- package/dist/.claude/commands/review.md +151 -0
- package/dist/.claude/commands/security.md +100 -0
- package/dist/.claude/commands/test.md +96 -0
- package/dist/.claude/commands/thumper.md +116 -0
- package/dist/.claude/commands/treasury.md +100 -0
- package/dist/.claude/commands/ux.md +118 -0
- package/dist/.claude/commands/vault.md +189 -0
- package/dist/.claude/commands/void.md +108 -0
- package/dist/CHANGELOG.md +1918 -0
- package/dist/CLAUDE.md +250 -0
- package/dist/HOLOCRON.md +856 -0
- package/dist/VERSION.md +123 -0
- package/dist/docs/NAMING_REGISTRY.md +478 -0
- package/dist/docs/methods/AI_INTELLIGENCE.md +276 -0
- package/dist/docs/methods/ASSEMBLER.md +142 -0
- package/dist/docs/methods/BACKEND_ENGINEER.md +165 -0
- package/dist/docs/methods/BUILD_JOURNAL.md +185 -0
- package/dist/docs/methods/BUILD_PROTOCOL.md +426 -0
- package/dist/docs/methods/CAMPAIGN.md +568 -0
- package/dist/docs/methods/CONTEXT_MANAGEMENT.md +189 -0
- package/dist/docs/methods/DEEP_CURRENT.md +184 -0
- package/dist/docs/methods/DEVOPS_ENGINEER.md +295 -0
- package/dist/docs/methods/FIELD_MEDIC.md +261 -0
- package/dist/docs/methods/FORGE_ARTIST.md +108 -0
- package/dist/docs/methods/FORGE_KEEPER.md +268 -0
- package/dist/docs/methods/GAUNTLET.md +344 -0
- package/dist/docs/methods/GROWTH_STRATEGIST.md +466 -0
- package/dist/docs/methods/HEARTBEAT.md +168 -0
- package/dist/docs/methods/MCP_INTEGRATION.md +139 -0
- package/dist/docs/methods/MUSTER.md +148 -0
- package/dist/docs/methods/PRD_GENERATOR.md +186 -0
- package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +250 -0
- package/dist/docs/methods/QA_ENGINEER.md +337 -0
- package/dist/docs/methods/RELEASE_MANAGER.md +145 -0
- package/dist/docs/methods/SECURITY_AUDITOR.md +320 -0
- package/dist/docs/methods/SUB_AGENTS.md +335 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +171 -0
- package/dist/docs/methods/TESTING.md +359 -0
- package/dist/docs/methods/THUMPER.md +175 -0
- package/dist/docs/methods/TIME_VAULT.md +120 -0
- package/dist/docs/methods/TREASURY.md +184 -0
- package/dist/docs/methods/TROUBLESHOOTING.md +265 -0
- package/dist/docs/patterns/README.md +52 -0
- package/dist/docs/patterns/ad-billing-adapter.ts +537 -0
- package/dist/docs/patterns/ad-platform-adapter.ts +421 -0
- package/dist/docs/patterns/ai-classifier.ts +195 -0
- package/dist/docs/patterns/ai-eval.ts +272 -0
- package/dist/docs/patterns/ai-orchestrator.ts +341 -0
- package/dist/docs/patterns/ai-router.ts +194 -0
- package/dist/docs/patterns/ai-tool-schema.ts +237 -0
- package/dist/docs/patterns/api-route.ts +241 -0
- package/dist/docs/patterns/backtest-engine.ts +499 -0
- package/dist/docs/patterns/browser-review.ts +292 -0
- package/dist/docs/patterns/combobox.tsx +300 -0
- package/dist/docs/patterns/component.tsx +262 -0
- package/dist/docs/patterns/daemon-process.ts +338 -0
- package/dist/docs/patterns/data-pipeline.ts +297 -0
- package/dist/docs/patterns/database-migration.ts +466 -0
- package/dist/docs/patterns/e2e-test.ts +629 -0
- package/dist/docs/patterns/error-handling.ts +312 -0
- package/dist/docs/patterns/execution-safety.ts +601 -0
- package/dist/docs/patterns/financial-transaction.ts +342 -0
- package/dist/docs/patterns/funding-plan.ts +462 -0
- package/dist/docs/patterns/game-entity.ts +137 -0
- package/dist/docs/patterns/game-loop.ts +113 -0
- package/dist/docs/patterns/game-state.ts +143 -0
- package/dist/docs/patterns/job-queue.ts +225 -0
- package/dist/docs/patterns/kongo-integration.ts +164 -0
- package/dist/docs/patterns/middleware.ts +363 -0
- package/dist/docs/patterns/mobile-screen.tsx +139 -0
- package/dist/docs/patterns/mobile-service.ts +167 -0
- package/dist/docs/patterns/multi-tenant.ts +382 -0
- package/dist/docs/patterns/oauth-token-lifecycle.ts +223 -0
- package/dist/docs/patterns/outbound-rate-limiter.ts +260 -0
- package/dist/docs/patterns/prompt-template.ts +195 -0
- package/dist/docs/patterns/revenue-source-adapter.ts +311 -0
- package/dist/docs/patterns/service.ts +224 -0
- package/dist/docs/patterns/sse-endpoint.ts +118 -0
- package/dist/docs/patterns/stablecoin-adapter.ts +511 -0
- package/dist/docs/patterns/third-party-script.ts +68 -0
- package/dist/scripts/thumper/gom-jabbar.sh +241 -0
- package/dist/scripts/thumper/relay.sh +610 -0
- package/dist/scripts/thumper/scan.sh +359 -0
- package/dist/scripts/thumper/thumper.sh +190 -0
- package/dist/scripts/thumper/water-rings.sh +76 -0
- package/dist/wizard/ui/index.html +1 -1
- package/package.json +1 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Multi-Tenancy (Workspace/Organization Scoping)
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Every query is scoped to the tenant (workspace/org)
|
|
6
|
+
* - Tenant context is resolved once in middleware, passed downstream
|
|
7
|
+
* - Never trust client-provided tenant IDs — derive from session
|
|
8
|
+
* - Cross-tenant data access is impossible by default
|
|
9
|
+
* - Admin/superadmin access is explicit and audited
|
|
10
|
+
*
|
|
11
|
+
* Agents: Ahsoka (access control), Strange (service architecture), Kenobi (security)
|
|
12
|
+
*
|
|
13
|
+
* Framework adaptations:
|
|
14
|
+
* Next.js: Middleware + headers, or context in server components
|
|
15
|
+
* Django: Middleware + thread-local or django-tenants
|
|
16
|
+
* Rails: ActsAsTenant gem, or Current.workspace pattern
|
|
17
|
+
* Express: Middleware + req.workspace
|
|
18
|
+
*
|
|
19
|
+
* === Django Deep Dive (django-tenants) ===
|
|
20
|
+
*
|
|
21
|
+
* # Schema-per-tenant: each org gets its own Postgres schema
|
|
22
|
+
* # pip install django-tenants
|
|
23
|
+
* # settings.py: DATABASE_ROUTERS, TENANT_MODEL, SHARED_APPS, TENANT_APPS
|
|
24
|
+
*
|
|
25
|
+
* # Alternative: shared schema with org_id filtering
|
|
26
|
+
* class TenantMiddleware:
|
|
27
|
+
* def __call__(self, request):
|
|
28
|
+
* request.org = get_org_from_subdomain(request)
|
|
29
|
+
* return self.get_response(request)
|
|
30
|
+
*
|
|
31
|
+
* # Every QuerySet filtered by org: Project.objects.filter(org=request.org)
|
|
32
|
+
* # Custom manager: class TenantManager: def for_org(self, org): return self.filter(org=org)
|
|
33
|
+
* # CRITICAL: never use .all() on tenant-scoped models — always .for_org(org)
|
|
34
|
+
*
|
|
35
|
+
* === FastAPI Deep Dive ===
|
|
36
|
+
*
|
|
37
|
+
* # Depends() for tenant scoping — injected into every route
|
|
38
|
+
* async def get_current_org(request: Request, user = Depends(get_current_user)):
|
|
39
|
+
* org_id = request.headers.get("X-Org-Id") or user.default_org_id
|
|
40
|
+
* org = await OrgService.get(org_id)
|
|
41
|
+
* if not org or user.id not in org.member_ids:
|
|
42
|
+
* raise HTTPException(403, "Not a member of this organization")
|
|
43
|
+
* return org
|
|
44
|
+
*
|
|
45
|
+
* @router.get("/projects")
|
|
46
|
+
* async def list_projects(org = Depends(get_current_org), db = Depends(get_db)):
|
|
47
|
+
* return await db.execute(select(Project).where(Project.org_id == org.id))
|
|
48
|
+
*
|
|
49
|
+
* # Same principle: every query scoped by org. Never trust client-provided org_id
|
|
50
|
+
* # without verifying membership.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
// --- Tenant resolution middleware ---
|
|
54
|
+
|
|
55
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
56
|
+
import { getSession } from '@/lib/auth'
|
|
57
|
+
import { db } from '@/lib/db'
|
|
58
|
+
|
|
59
|
+
export async function tenantMiddleware(req: NextRequest) {
|
|
60
|
+
const session = await getSession(req)
|
|
61
|
+
if (!session) {
|
|
62
|
+
return NextResponse.json(
|
|
63
|
+
{ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } },
|
|
64
|
+
{ status: 401 }
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Extract workspace from URL (e.g., /api/workspaces/:id/projects)
|
|
69
|
+
const workspaceId = req.nextUrl.pathname.match(
|
|
70
|
+
/\/api\/workspaces\/([^/]+)/
|
|
71
|
+
)?.[1]
|
|
72
|
+
|
|
73
|
+
if (!workspaceId) {
|
|
74
|
+
return NextResponse.next() // Not a workspace-scoped route
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Verify membership — NEVER trust the URL alone (Ahsoka — access control)
|
|
78
|
+
const membership = await db.workspaceMember.findUnique({
|
|
79
|
+
where: {
|
|
80
|
+
workspaceId_userId: {
|
|
81
|
+
workspaceId,
|
|
82
|
+
userId: session.userId,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
if (!membership) {
|
|
88
|
+
// Return 404, not 403 — don't reveal workspace existence
|
|
89
|
+
return NextResponse.json(
|
|
90
|
+
{ error: { code: 'NOT_FOUND', message: 'Not found' } },
|
|
91
|
+
{ status: 404 }
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Pass tenant context downstream via headers
|
|
96
|
+
const response = NextResponse.next()
|
|
97
|
+
response.headers.set('x-workspace-id', workspaceId)
|
|
98
|
+
response.headers.set('x-workspace-role', membership.role)
|
|
99
|
+
return response
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Tenant-scoped service ---
|
|
103
|
+
|
|
104
|
+
interface TenantContext {
|
|
105
|
+
workspaceId: string
|
|
106
|
+
userId: string
|
|
107
|
+
role: 'owner' | 'admin' | 'member' | 'viewer'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const projectService = {
|
|
111
|
+
// Every query includes the tenant scope — no exceptions
|
|
112
|
+
async list(ctx: TenantContext) {
|
|
113
|
+
return db.project.findMany({
|
|
114
|
+
where: { workspaceId: ctx.workspaceId }, // Always scoped
|
|
115
|
+
orderBy: { createdAt: 'desc' },
|
|
116
|
+
})
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async getById(ctx: TenantContext, projectId: string) {
|
|
120
|
+
const project = await db.project.findUnique({
|
|
121
|
+
where: { id: projectId },
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Double-check tenant scope — defense in depth (Kenobi)
|
|
125
|
+
if (!project || project.workspaceId !== ctx.workspaceId) {
|
|
126
|
+
throw new ApiError('NOT_FOUND', 'Project not found', 404)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return project
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async create(ctx: TenantContext, input: { name: string }) {
|
|
133
|
+
// Role check — only owners, admins, and members can create
|
|
134
|
+
if (ctx.role === 'viewer') {
|
|
135
|
+
throw new ApiError('FORBIDDEN', 'Viewers cannot create projects', 403)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return db.project.create({
|
|
139
|
+
data: {
|
|
140
|
+
name: input.name,
|
|
141
|
+
workspaceId: ctx.workspaceId,
|
|
142
|
+
createdById: ctx.userId,
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async delete(ctx: TenantContext, projectId: string) {
|
|
148
|
+
// Only owners and admins can delete
|
|
149
|
+
if (!['owner', 'admin'].includes(ctx.role)) {
|
|
150
|
+
throw new ApiError('FORBIDDEN', 'Insufficient permissions', 403)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const project = await this.getById(ctx, projectId)
|
|
154
|
+
|
|
155
|
+
await db.project.delete({ where: { id: project.id } })
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- Route handler using tenant context ---
|
|
160
|
+
|
|
161
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
162
|
+
|
|
163
|
+
export async function GET(
|
|
164
|
+
req: NextRequest,
|
|
165
|
+
{ params }: { params: { workspaceId: string } }
|
|
166
|
+
) {
|
|
167
|
+
const ctx: TenantContext = {
|
|
168
|
+
workspaceId: req.headers.get('x-workspace-id')!,
|
|
169
|
+
userId: req.headers.get('x-user-id')!,
|
|
170
|
+
role: req.headers.get('x-workspace-role') as TenantContext['role'],
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const projects = await projectService.list(ctx)
|
|
174
|
+
return NextResponse.json({ projects })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- Schema pattern (Prisma) ---
|
|
178
|
+
/*
|
|
179
|
+
model Workspace {
|
|
180
|
+
id String @id @default(cuid())
|
|
181
|
+
name String
|
|
182
|
+
createdAt DateTime @default(now())
|
|
183
|
+
updatedAt DateTime @updatedAt
|
|
184
|
+
|
|
185
|
+
members WorkspaceMember[]
|
|
186
|
+
projects Project[]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
model WorkspaceMember {
|
|
190
|
+
workspaceId String
|
|
191
|
+
userId String
|
|
192
|
+
role WorkspaceRole @default(MEMBER)
|
|
193
|
+
joinedAt DateTime @default(now())
|
|
194
|
+
|
|
195
|
+
workspace Workspace @relation(fields: [workspaceId], references: [id])
|
|
196
|
+
user User @relation(fields: [userId], references: [id])
|
|
197
|
+
|
|
198
|
+
@@id([workspaceId, userId])
|
|
199
|
+
@@index([userId]) // Fast lookup: "which workspaces is this user in?"
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
enum WorkspaceRole {
|
|
203
|
+
OWNER
|
|
204
|
+
ADMIN
|
|
205
|
+
MEMBER
|
|
206
|
+
VIEWER
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
model Project {
|
|
210
|
+
id String @id @default(cuid())
|
|
211
|
+
name String
|
|
212
|
+
workspaceId String // Always scoped to workspace
|
|
213
|
+
createdById String
|
|
214
|
+
createdAt DateTime @default(now())
|
|
215
|
+
updatedAt DateTime @updatedAt
|
|
216
|
+
|
|
217
|
+
workspace Workspace @relation(fields: [workspaceId], references: [id])
|
|
218
|
+
createdBy User @relation(fields: [createdById], references: [id])
|
|
219
|
+
|
|
220
|
+
@@index([workspaceId]) // All queries filter by workspace
|
|
221
|
+
}
|
|
222
|
+
*/
|
|
223
|
+
|
|
224
|
+
// --- Composite primary keys for org-scoped tables ---
|
|
225
|
+
// Tables with natural keys need UNIQUE(natural_key, org_id) to prevent
|
|
226
|
+
// cross-tenant collisions. A widgetId might be unique within Org A but
|
|
227
|
+
// Org B can independently create the same widgetId. Without a composite
|
|
228
|
+
// constraint the second insert silently overwrites or fails.
|
|
229
|
+
//
|
|
230
|
+
// Prisma schema:
|
|
231
|
+
/*
|
|
232
|
+
model WidgetState {
|
|
233
|
+
id String @id @default(cuid())
|
|
234
|
+
widgetId String // Natural key — unique per org, not globally
|
|
235
|
+
workspaceId String
|
|
236
|
+
state Json
|
|
237
|
+
updatedAt DateTime @updatedAt
|
|
238
|
+
|
|
239
|
+
workspace Workspace @relation(fields: [workspaceId], references: [id])
|
|
240
|
+
|
|
241
|
+
@@unique([widgetId, workspaceId]) // Prevents cross-tenant collision
|
|
242
|
+
@@index([workspaceId]) // Tenant-scoped queries stay fast
|
|
243
|
+
}
|
|
244
|
+
*/
|
|
245
|
+
//
|
|
246
|
+
// Django equivalent:
|
|
247
|
+
// class Meta:
|
|
248
|
+
// constraints = [
|
|
249
|
+
// models.UniqueConstraint(fields=['widget_id', 'org'], name='unique_widget_per_org')
|
|
250
|
+
// ]
|
|
251
|
+
//
|
|
252
|
+
// Rails equivalent:
|
|
253
|
+
// add_index :widget_states, [:widget_id, :workspace_id], unique: true
|
|
254
|
+
//
|
|
255
|
+
// Rule: any column that acts as a "slug", "external_id", or "name" within
|
|
256
|
+
// a tenant MUST have a composite unique constraint including the tenant FK.
|
|
257
|
+
|
|
258
|
+
// --- Django equivalent ---
|
|
259
|
+
/*
|
|
260
|
+
# middleware.py
|
|
261
|
+
class TenantMiddleware:
|
|
262
|
+
def __call__(self, request):
|
|
263
|
+
workspace_id = resolve_workspace_from_url(request.path)
|
|
264
|
+
if workspace_id:
|
|
265
|
+
membership = WorkspaceMember.objects.filter(
|
|
266
|
+
workspace_id=workspace_id, user=request.user
|
|
267
|
+
).first()
|
|
268
|
+
if not membership:
|
|
269
|
+
return JsonResponse({'error': 'Not found'}, status=404)
|
|
270
|
+
request.workspace = membership.workspace
|
|
271
|
+
request.workspace_role = membership.role
|
|
272
|
+
return self.get_response(request)
|
|
273
|
+
|
|
274
|
+
# services.py
|
|
275
|
+
class ProjectService:
|
|
276
|
+
@staticmethod
|
|
277
|
+
def list(workspace):
|
|
278
|
+
return Project.objects.filter(workspace=workspace)
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def create(workspace, user, name):
|
|
282
|
+
return Project.objects.create(
|
|
283
|
+
workspace=workspace, created_by=user, name=name
|
|
284
|
+
)
|
|
285
|
+
*/
|
|
286
|
+
|
|
287
|
+
// --- Rails equivalent ---
|
|
288
|
+
/*
|
|
289
|
+
# app/controllers/concerns/tenant_scoped.rb
|
|
290
|
+
module TenantScoped
|
|
291
|
+
extend ActiveSupport::Concern
|
|
292
|
+
|
|
293
|
+
before_action :set_workspace
|
|
294
|
+
|
|
295
|
+
private
|
|
296
|
+
|
|
297
|
+
def set_workspace
|
|
298
|
+
@workspace = current_user.workspaces.find_by(id: params[:workspace_id])
|
|
299
|
+
head :not_found unless @workspace
|
|
300
|
+
@membership = @workspace.memberships.find_by(user: current_user)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def authorize_role!(*roles)
|
|
304
|
+
head :forbidden unless roles.include?(@membership.role.to_sym)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
*/
|
|
308
|
+
|
|
309
|
+
// --- Per-tenant credential store ---
|
|
310
|
+
// Encrypted API key storage — keys never leave the server in plaintext.
|
|
311
|
+
// AES-256-GCM at rest, key derived from org-specific master key.
|
|
312
|
+
// Write-only admin API: responses contain metadata only (provider, timestamps).
|
|
313
|
+
|
|
314
|
+
interface TenantCredential {
|
|
315
|
+
orgId: string
|
|
316
|
+
provider: string // e.g. 'stripe', 'openai', 'tiktok'
|
|
317
|
+
encryptedKey: Buffer // AES-256-GCM ciphertext + IV + auth tag
|
|
318
|
+
verifiedAt: Date | null // Last successful probe — null if never verified
|
|
319
|
+
revokedAt: Date | null // Soft-delete: non-null means revoked
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface CredentialMetadata {
|
|
323
|
+
provider: string
|
|
324
|
+
createdAt: Date
|
|
325
|
+
verifiedAt: Date | null
|
|
326
|
+
isRevoked: boolean
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
class CredentialStore {
|
|
330
|
+
constructor(private readonly masterKeyProvider: (orgId: string) => Promise<Buffer>) {}
|
|
331
|
+
|
|
332
|
+
/** Store a new credential. Runs a verification probe before persisting. */
|
|
333
|
+
async store(orgId: string, provider: string, plainKey: string): Promise<void> {
|
|
334
|
+
await this.verifyProbe(provider, plainKey) // Fail fast if key is invalid
|
|
335
|
+
const masterKey = await this.masterKeyProvider(orgId)
|
|
336
|
+
const encrypted = encryptAes256Gcm(plainKey, masterKey)
|
|
337
|
+
await db.tenantCredential.create({
|
|
338
|
+
data: { orgId, provider, encryptedKey: encrypted, verifiedAt: new Date() },
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Decrypt for server-side use only — NEVER expose in an API response. */
|
|
343
|
+
async getDecrypted(orgId: string, provider: string): Promise<string> {
|
|
344
|
+
const cred = await this.findActiveCredential(orgId, provider)
|
|
345
|
+
const masterKey = await this.masterKeyProvider(orgId)
|
|
346
|
+
return decryptAes256Gcm(cred.encryptedKey, masterKey)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** List credentials as metadata — no secrets returned. */
|
|
350
|
+
async listMetadata(orgId: string): Promise<CredentialMetadata[]> {
|
|
351
|
+
const creds = await db.tenantCredential.findMany({ where: { orgId } })
|
|
352
|
+
return creds.map(c => ({
|
|
353
|
+
provider: c.provider,
|
|
354
|
+
createdAt: c.createdAt,
|
|
355
|
+
verifiedAt: c.verifiedAt,
|
|
356
|
+
isRevoked: c.revokedAt !== null,
|
|
357
|
+
}))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Soft-revoke: marks revokedAt, old key ignored after grace period. */
|
|
361
|
+
async revoke(orgId: string, provider: string): Promise<void> {
|
|
362
|
+
await db.tenantCredential.updateMany({
|
|
363
|
+
where: { orgId, provider, revokedAt: null },
|
|
364
|
+
data: { revokedAt: new Date() },
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private async findActiveCredential(orgId: string, provider: string) {
|
|
369
|
+
const cred = await db.tenantCredential.findFirst({
|
|
370
|
+
where: { orgId, provider, revokedAt: null },
|
|
371
|
+
orderBy: { createdAt: 'desc' },
|
|
372
|
+
})
|
|
373
|
+
if (!cred) throw new ApiError('NOT_FOUND', `No active credential for ${provider}`, 404)
|
|
374
|
+
return cred
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Provider-specific liveness check before storing a key. */
|
|
378
|
+
private async verifyProbe(provider: string, key: string): Promise<void> {
|
|
379
|
+
// Each provider adapter exposes a lightweight health endpoint.
|
|
380
|
+
// Throws ApiError('INVALID_CREDENTIAL') if the key fails.
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: OAuth Token Lifecycle
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Refresh at 80% of TTL (not at expiry — prevents race conditions)
|
|
6
|
+
* - Multi-grant-type support (authorization_code, refresh_token)
|
|
7
|
+
* - Vault integration — tokens encrypted at rest in financial vault
|
|
8
|
+
* - Failure escalation: retry 3x → pause platform → alert → requires_reauth
|
|
9
|
+
* - Token stored as encrypted blob in vault, keyed by platform name
|
|
10
|
+
* - Session token (daemon) rotates every 24 hours (§9.19.15)
|
|
11
|
+
*
|
|
12
|
+
* Agents: Breeze (platform relations), Dockson (vault)
|
|
13
|
+
*
|
|
14
|
+
* PRD Reference: §9.5 (token refresh strategy), §9.18 (vault session), §9.19.15
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type AdPlatform = 'meta' | 'google' | 'tiktok' | 'linkedin' | 'twitter' | 'reddit';
|
|
18
|
+
|
|
19
|
+
// ── Token Types ───────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
interface OAuthTokenSet {
|
|
22
|
+
platform: AdPlatform;
|
|
23
|
+
accessToken: string;
|
|
24
|
+
refreshToken: string;
|
|
25
|
+
expiresAt: string; // ISO 8601
|
|
26
|
+
tokenType: string; // 'Bearer'
|
|
27
|
+
scopes: string[];
|
|
28
|
+
grantedAt: string; // ISO 8601 — when the token was first obtained
|
|
29
|
+
lastRefreshedAt: string; // ISO 8601 — when the token was last refreshed
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type TokenHealth = 'healthy' | 'expiring_soon' | 'expired' | 'refresh_failed' | 'requires_reauth' | 'revoked';
|
|
33
|
+
|
|
34
|
+
interface TokenStatus {
|
|
35
|
+
platform: AdPlatform;
|
|
36
|
+
health: TokenHealth;
|
|
37
|
+
expiresAt: string;
|
|
38
|
+
lastRefresh: string;
|
|
39
|
+
consecutiveFailures: number;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Platform TTL Configuration ────────────────────────
|
|
44
|
+
|
|
45
|
+
interface PlatformTokenConfig {
|
|
46
|
+
platform: AdPlatform;
|
|
47
|
+
accessTokenTtlHours: number; // How long the access token lives
|
|
48
|
+
refreshTokenTtlDays: number; // How long the refresh token lives (0 = never expires)
|
|
49
|
+
refreshEndpoint: string;
|
|
50
|
+
revokeEndpoint?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PLATFORM_CONFIGS: PlatformTokenConfig[] = [
|
|
54
|
+
{ platform: 'meta', accessTokenTtlHours: 1440, refreshTokenTtlDays: 0, refreshEndpoint: 'https://graph.facebook.com/v19.0/oauth/access_token' },
|
|
55
|
+
{ platform: 'google', accessTokenTtlHours: 1, refreshTokenTtlDays: 0, refreshEndpoint: 'https://oauth2.googleapis.com/token' },
|
|
56
|
+
{ platform: 'tiktok', accessTokenTtlHours: 24, refreshTokenTtlDays: 365, refreshEndpoint: 'https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token/' },
|
|
57
|
+
{ platform: 'linkedin', accessTokenTtlHours: 1440, refreshTokenTtlDays: 365, refreshEndpoint: 'https://www.linkedin.com/oauth/v2/accessToken' },
|
|
58
|
+
{ platform: 'twitter', accessTokenTtlHours: 0, refreshTokenTtlDays: 0, refreshEndpoint: '' }, // OAuth 1.0a — tokens don't expire
|
|
59
|
+
{ platform: 'reddit', accessTokenTtlHours: 1, refreshTokenTtlDays: 0, refreshEndpoint: 'https://www.reddit.com/api/v1/access_token' },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// ── Refresh Logic ─────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
const REFRESH_AT_TTL_PERCENT = 0.80; // Refresh at 80% of TTL
|
|
65
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if a token needs refresh.
|
|
69
|
+
* Returns true if the token is past 80% of its TTL.
|
|
70
|
+
*/
|
|
71
|
+
function needsRefresh(token: OAuthTokenSet): boolean {
|
|
72
|
+
const config = PLATFORM_CONFIGS.find(c => c.platform === token.platform);
|
|
73
|
+
if (!config || config.accessTokenTtlHours === 0) return false; // Never expires (Twitter OAuth 1.0a)
|
|
74
|
+
|
|
75
|
+
const expiresAt = new Date(token.expiresAt).getTime();
|
|
76
|
+
const lastRefresh = new Date(token.lastRefreshedAt).getTime();
|
|
77
|
+
const ttlMs = config.accessTokenTtlHours * 60 * 60 * 1000;
|
|
78
|
+
const refreshAt = lastRefresh + (ttlMs * REFRESH_AT_TTL_PERCENT);
|
|
79
|
+
|
|
80
|
+
return Date.now() >= refreshAt;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Determine the health status of a token.
|
|
85
|
+
*/
|
|
86
|
+
function getTokenHealth(token: OAuthTokenSet, failures: number): TokenHealth {
|
|
87
|
+
if (failures >= MAX_CONSECUTIVE_FAILURES) return 'requires_reauth';
|
|
88
|
+
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const expiresAt = new Date(token.expiresAt).getTime();
|
|
91
|
+
|
|
92
|
+
if (expiresAt < now) return 'expired';
|
|
93
|
+
|
|
94
|
+
const config = PLATFORM_CONFIGS.find(c => c.platform === token.platform);
|
|
95
|
+
if (!config || config.accessTokenTtlHours === 0) return 'healthy'; // Never expires
|
|
96
|
+
|
|
97
|
+
const ttlMs = config.accessTokenTtlHours * 60 * 60 * 1000;
|
|
98
|
+
const warningAt = expiresAt - (ttlMs * 0.2); // Warn at 80% consumed
|
|
99
|
+
|
|
100
|
+
if (now >= warningAt) return 'expiring_soon';
|
|
101
|
+
if (failures > 0) return 'refresh_failed';
|
|
102
|
+
return 'healthy';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Failure Escalation (§9.5) ─────────────────────────
|
|
106
|
+
|
|
107
|
+
interface RefreshResult {
|
|
108
|
+
success: boolean;
|
|
109
|
+
newTokens?: OAuthTokenSet;
|
|
110
|
+
error?: string;
|
|
111
|
+
requiresReauth?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Handle a refresh failure. Implements the escalation:
|
|
116
|
+
* 1st failure: retry after 30s
|
|
117
|
+
* 2nd failure: retry after 60s
|
|
118
|
+
* 3rd failure: pause campaigns on this platform, set requires_reauth, alert
|
|
119
|
+
*
|
|
120
|
+
* The `invalid_grant` error means the refresh token itself is expired/revoked.
|
|
121
|
+
* This does NOT count toward the 3-failure pause trigger (§9.18) —
|
|
122
|
+
* it goes straight to requires_reauth.
|
|
123
|
+
*/
|
|
124
|
+
function handleRefreshFailure(
|
|
125
|
+
platform: AdPlatform,
|
|
126
|
+
error: string,
|
|
127
|
+
consecutiveFailures: number
|
|
128
|
+
): { action: 'retry' | 'pause_and_alert' | 'reauth'; retryAfterMs?: number } {
|
|
129
|
+
// invalid_grant = refresh token revoked/expired — immediate reauth
|
|
130
|
+
if (error.includes('invalid_grant') || error.includes('revoked')) {
|
|
131
|
+
return { action: 'reauth' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const newCount = consecutiveFailures + 1;
|
|
135
|
+
if (newCount >= MAX_CONSECUTIVE_FAILURES) {
|
|
136
|
+
return { action: 'pause_and_alert' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Exponential backoff: 30s, 60s
|
|
140
|
+
const retryAfterMs = 30000 * Math.pow(2, consecutiveFailures);
|
|
141
|
+
return { action: 'retry', retryAfterMs };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Vault Integration ─────────────────────────────────
|
|
145
|
+
// Tokens are stored in the financial vault, keyed by platform name.
|
|
146
|
+
// The daemon reads them at startup and holds them in memory.
|
|
147
|
+
// On refresh, the daemon writes the updated token back to the vault.
|
|
148
|
+
|
|
149
|
+
const TOKEN_VAULT_KEY_PREFIX = 'growth/tokens/';
|
|
150
|
+
|
|
151
|
+
function tokenVaultKey(platform: AdPlatform): string {
|
|
152
|
+
return TOKEN_VAULT_KEY_PREFIX + platform;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Serialize tokens for vault storage.
|
|
157
|
+
* The vault stores strings — tokens are JSON-serialized.
|
|
158
|
+
*/
|
|
159
|
+
function serializeTokens(tokens: OAuthTokenSet): string {
|
|
160
|
+
return JSON.stringify(tokens);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function deserializeTokens(data: string): OAuthTokenSet {
|
|
164
|
+
return JSON.parse(data) as OAuthTokenSet;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Session Token Rotation (§9.19.15) ─────────────────
|
|
168
|
+
// The daemon session token (heartbeat.token) rotates every 24 hours.
|
|
169
|
+
// During rotation, accept both old and new tokens for 30-second grace period.
|
|
170
|
+
|
|
171
|
+
const SESSION_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
172
|
+
const SESSION_TOKEN_GRACE_MS = 30 * 1000; // 30 seconds
|
|
173
|
+
|
|
174
|
+
interface SessionTokenState {
|
|
175
|
+
current: string;
|
|
176
|
+
previous?: string;
|
|
177
|
+
rotatedAt: number;
|
|
178
|
+
previousExpiresAt?: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function shouldRotateSessionToken(state: SessionTokenState): boolean {
|
|
182
|
+
return Date.now() - state.rotatedAt >= SESSION_TOKEN_TTL_MS;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function rotateSessionToken(state: SessionTokenState, newToken: string): SessionTokenState {
|
|
186
|
+
return {
|
|
187
|
+
current: newToken,
|
|
188
|
+
previous: state.current,
|
|
189
|
+
rotatedAt: Date.now(),
|
|
190
|
+
previousExpiresAt: Date.now() + SESSION_TOKEN_GRACE_MS,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validateSessionToken(provided: string, state: SessionTokenState): boolean {
|
|
195
|
+
const { timingSafeEqual } = require('node:crypto');
|
|
196
|
+
|
|
197
|
+
// Check current token
|
|
198
|
+
if (provided.length === state.current.length) {
|
|
199
|
+
const a = Buffer.from(provided);
|
|
200
|
+
const b = Buffer.from(state.current);
|
|
201
|
+
if (timingSafeEqual(a, b)) return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Check previous token during grace period
|
|
205
|
+
if (state.previous && state.previousExpiresAt && Date.now() < state.previousExpiresAt) {
|
|
206
|
+
if (provided.length === state.previous.length) {
|
|
207
|
+
const a = Buffer.from(provided);
|
|
208
|
+
const b = Buffer.from(state.previous);
|
|
209
|
+
if (timingSafeEqual(a, b)) return true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export type { OAuthTokenSet, TokenHealth, TokenStatus, PlatformTokenConfig, RefreshResult, SessionTokenState };
|
|
217
|
+
export {
|
|
218
|
+
PLATFORM_CONFIGS, REFRESH_AT_TTL_PERCENT, MAX_CONSECUTIVE_FAILURES,
|
|
219
|
+
needsRefresh, getTokenHealth, handleRefreshFailure,
|
|
220
|
+
tokenVaultKey, serializeTokens, deserializeTokens,
|
|
221
|
+
SESSION_TOKEN_TTL_MS, SESSION_TOKEN_GRACE_MS,
|
|
222
|
+
shouldRotateSessionToken, rotateSessionToken, validateSessionToken,
|
|
223
|
+
};
|