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.
Files changed (108) hide show
  1. package/dist/.claude/commands/ai.md +69 -0
  2. package/dist/.claude/commands/architect.md +121 -0
  3. package/dist/.claude/commands/assemble.md +201 -0
  4. package/dist/.claude/commands/assess.md +75 -0
  5. package/dist/.claude/commands/blueprint.md +135 -0
  6. package/dist/.claude/commands/build.md +116 -0
  7. package/dist/.claude/commands/campaign.md +201 -0
  8. package/dist/.claude/commands/cultivation.md +166 -0
  9. package/dist/.claude/commands/current.md +128 -0
  10. package/dist/.claude/commands/dangerroom.md +74 -0
  11. package/dist/.claude/commands/debrief.md +178 -0
  12. package/dist/.claude/commands/deploy.md +99 -0
  13. package/dist/.claude/commands/devops.md +143 -0
  14. package/dist/.claude/commands/gauntlet.md +140 -0
  15. package/dist/.claude/commands/git.md +104 -0
  16. package/dist/.claude/commands/grow.md +146 -0
  17. package/dist/.claude/commands/imagine.md +126 -0
  18. package/dist/.claude/commands/portfolio.md +50 -0
  19. package/dist/.claude/commands/prd.md +113 -0
  20. package/dist/.claude/commands/qa.md +107 -0
  21. package/dist/.claude/commands/review.md +151 -0
  22. package/dist/.claude/commands/security.md +100 -0
  23. package/dist/.claude/commands/test.md +96 -0
  24. package/dist/.claude/commands/thumper.md +116 -0
  25. package/dist/.claude/commands/treasury.md +100 -0
  26. package/dist/.claude/commands/ux.md +118 -0
  27. package/dist/.claude/commands/vault.md +189 -0
  28. package/dist/.claude/commands/void.md +108 -0
  29. package/dist/CHANGELOG.md +1918 -0
  30. package/dist/CLAUDE.md +250 -0
  31. package/dist/HOLOCRON.md +856 -0
  32. package/dist/VERSION.md +123 -0
  33. package/dist/docs/NAMING_REGISTRY.md +478 -0
  34. package/dist/docs/methods/AI_INTELLIGENCE.md +276 -0
  35. package/dist/docs/methods/ASSEMBLER.md +142 -0
  36. package/dist/docs/methods/BACKEND_ENGINEER.md +165 -0
  37. package/dist/docs/methods/BUILD_JOURNAL.md +185 -0
  38. package/dist/docs/methods/BUILD_PROTOCOL.md +426 -0
  39. package/dist/docs/methods/CAMPAIGN.md +568 -0
  40. package/dist/docs/methods/CONTEXT_MANAGEMENT.md +189 -0
  41. package/dist/docs/methods/DEEP_CURRENT.md +184 -0
  42. package/dist/docs/methods/DEVOPS_ENGINEER.md +295 -0
  43. package/dist/docs/methods/FIELD_MEDIC.md +261 -0
  44. package/dist/docs/methods/FORGE_ARTIST.md +108 -0
  45. package/dist/docs/methods/FORGE_KEEPER.md +268 -0
  46. package/dist/docs/methods/GAUNTLET.md +344 -0
  47. package/dist/docs/methods/GROWTH_STRATEGIST.md +466 -0
  48. package/dist/docs/methods/HEARTBEAT.md +168 -0
  49. package/dist/docs/methods/MCP_INTEGRATION.md +139 -0
  50. package/dist/docs/methods/MUSTER.md +148 -0
  51. package/dist/docs/methods/PRD_GENERATOR.md +186 -0
  52. package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +250 -0
  53. package/dist/docs/methods/QA_ENGINEER.md +337 -0
  54. package/dist/docs/methods/RELEASE_MANAGER.md +145 -0
  55. package/dist/docs/methods/SECURITY_AUDITOR.md +320 -0
  56. package/dist/docs/methods/SUB_AGENTS.md +335 -0
  57. package/dist/docs/methods/SYSTEMS_ARCHITECT.md +171 -0
  58. package/dist/docs/methods/TESTING.md +359 -0
  59. package/dist/docs/methods/THUMPER.md +175 -0
  60. package/dist/docs/methods/TIME_VAULT.md +120 -0
  61. package/dist/docs/methods/TREASURY.md +184 -0
  62. package/dist/docs/methods/TROUBLESHOOTING.md +265 -0
  63. package/dist/docs/patterns/README.md +52 -0
  64. package/dist/docs/patterns/ad-billing-adapter.ts +537 -0
  65. package/dist/docs/patterns/ad-platform-adapter.ts +421 -0
  66. package/dist/docs/patterns/ai-classifier.ts +195 -0
  67. package/dist/docs/patterns/ai-eval.ts +272 -0
  68. package/dist/docs/patterns/ai-orchestrator.ts +341 -0
  69. package/dist/docs/patterns/ai-router.ts +194 -0
  70. package/dist/docs/patterns/ai-tool-schema.ts +237 -0
  71. package/dist/docs/patterns/api-route.ts +241 -0
  72. package/dist/docs/patterns/backtest-engine.ts +499 -0
  73. package/dist/docs/patterns/browser-review.ts +292 -0
  74. package/dist/docs/patterns/combobox.tsx +300 -0
  75. package/dist/docs/patterns/component.tsx +262 -0
  76. package/dist/docs/patterns/daemon-process.ts +338 -0
  77. package/dist/docs/patterns/data-pipeline.ts +297 -0
  78. package/dist/docs/patterns/database-migration.ts +466 -0
  79. package/dist/docs/patterns/e2e-test.ts +629 -0
  80. package/dist/docs/patterns/error-handling.ts +312 -0
  81. package/dist/docs/patterns/execution-safety.ts +601 -0
  82. package/dist/docs/patterns/financial-transaction.ts +342 -0
  83. package/dist/docs/patterns/funding-plan.ts +462 -0
  84. package/dist/docs/patterns/game-entity.ts +137 -0
  85. package/dist/docs/patterns/game-loop.ts +113 -0
  86. package/dist/docs/patterns/game-state.ts +143 -0
  87. package/dist/docs/patterns/job-queue.ts +225 -0
  88. package/dist/docs/patterns/kongo-integration.ts +164 -0
  89. package/dist/docs/patterns/middleware.ts +363 -0
  90. package/dist/docs/patterns/mobile-screen.tsx +139 -0
  91. package/dist/docs/patterns/mobile-service.ts +167 -0
  92. package/dist/docs/patterns/multi-tenant.ts +382 -0
  93. package/dist/docs/patterns/oauth-token-lifecycle.ts +223 -0
  94. package/dist/docs/patterns/outbound-rate-limiter.ts +260 -0
  95. package/dist/docs/patterns/prompt-template.ts +195 -0
  96. package/dist/docs/patterns/revenue-source-adapter.ts +311 -0
  97. package/dist/docs/patterns/service.ts +224 -0
  98. package/dist/docs/patterns/sse-endpoint.ts +118 -0
  99. package/dist/docs/patterns/stablecoin-adapter.ts +511 -0
  100. package/dist/docs/patterns/third-party-script.ts +68 -0
  101. package/dist/scripts/thumper/gom-jabbar.sh +241 -0
  102. package/dist/scripts/thumper/relay.sh +610 -0
  103. package/dist/scripts/thumper/scan.sh +359 -0
  104. package/dist/scripts/thumper/thumper.sh +190 -0
  105. package/dist/scripts/thumper/water-rings.sh +76 -0
  106. package/dist/wizard/ui/index.html +1 -1
  107. package/package.json +1 -1
  108. 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
+ };