@augmenting-integrations/create-spoke 8.3.0

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 (36) hide show
  1. package/dist/cli.d.ts +2 -0
  2. package/dist/cli.d.ts.map +1 -0
  3. package/dist/cli.js +80 -0
  4. package/dist/cli.js.map +1 -0
  5. package/package.json +29 -0
  6. package/templates/.env.example.tmpl +25 -0
  7. package/templates/.gitignore.tmpl +9 -0
  8. package/templates/README.md.tmpl +47 -0
  9. package/templates/next.config.ts.tmpl +11 -0
  10. package/templates/package.json.tmpl +35 -0
  11. package/templates/prisma/schema.prisma.tmpl +97 -0
  12. package/templates/prisma/seed.mjs.tmpl +41 -0
  13. package/templates/src/app/Providers.tsx.tmpl +25 -0
  14. package/templates/src/app/api/admin/users/[id]/impersonate/route.ts.tmpl +13 -0
  15. package/templates/src/app/api/auth/[...nextauth]/route.ts.tmpl +5 -0
  16. package/templates/src/app/api/auth/me/route.ts.tmpl +13 -0
  17. package/templates/src/app/api/billing/auto-recharge/route.ts.tmpl +6 -0
  18. package/templates/src/app/api/billing/balance/route.ts.tmpl +6 -0
  19. package/templates/src/app/api/billing/payment-intent/route.ts.tmpl +6 -0
  20. package/templates/src/app/api/billing/payment-methods/[id]/default/route.ts.tmpl +6 -0
  21. package/templates/src/app/api/billing/payment-methods/[id]/route.ts.tmpl +6 -0
  22. package/templates/src/app/api/billing/payment-methods/route.ts.tmpl +6 -0
  23. package/templates/src/app/api/billing/route.ts.tmpl +6 -0
  24. package/templates/src/app/api/billing/setup-intent/route.ts.tmpl +6 -0
  25. package/templates/src/app/api/billing/stripe-webhook/route.ts.tmpl +6 -0
  26. package/templates/src/app/api/billing/transactions/route.ts.tmpl +6 -0
  27. package/templates/src/app/api/invitations/[token]/route.ts.tmpl +7 -0
  28. package/templates/src/app/globals.css.tmpl +44 -0
  29. package/templates/src/app/layout.tsx.tmpl +42 -0
  30. package/templates/src/app/page.tsx.tmpl +36 -0
  31. package/templates/src/lib/auth.ts.tmpl +15 -0
  32. package/templates/src/lib/billing.ts.tmpl +11 -0
  33. package/templates/src/lib/db.ts.tmpl +21 -0
  34. package/templates/src/lib/users.ts.tmpl +21 -0
  35. package/templates/src/proxy.ts.tmpl +10 -0
  36. package/templates/tsconfig.json.tmpl +21 -0
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import { fileURLToPath } from "url";
7
+ import process from "process";
8
+ function parseArgs(argv) {
9
+ const args = argv.slice(2);
10
+ let name;
11
+ let slug;
12
+ let subdomain;
13
+ for (const a of args) {
14
+ if (a.startsWith("--slug=")) slug = a.slice("--slug=".length);
15
+ else if (a.startsWith("--subdomain=")) subdomain = a.slice("--subdomain=".length);
16
+ else if (!a.startsWith("--")) name = a;
17
+ }
18
+ if (!name) {
19
+ console.error("Usage: create-spoke <name> [--slug=foo] [--subdomain=bar]");
20
+ process.exit(1);
21
+ }
22
+ const safeName = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
23
+ return {
24
+ name,
25
+ slug: slug ?? safeName,
26
+ subdomain: subdomain ?? safeName.split("-")[0]
27
+ };
28
+ }
29
+ function templatesDir() {
30
+ const here = fileURLToPath(import.meta.url);
31
+ return path.resolve(path.dirname(here), "..", "templates");
32
+ }
33
+ function ensureEmptyDir(target) {
34
+ if (fs.existsSync(target)) {
35
+ const entries = fs.readdirSync(target);
36
+ if (entries.length > 0) {
37
+ console.error(`Refusing to write into non-empty directory: ${target}`);
38
+ process.exit(1);
39
+ }
40
+ } else {
41
+ fs.mkdirSync(target, { recursive: true });
42
+ }
43
+ }
44
+ function replaceVars(content, flags) {
45
+ return content.replace(/__SPOKE_NAME__/g, flags.name).replace(/__SPOKE_SLUG__/g, flags.slug).replace(/__SPOKE_SUBDOMAIN__/g, flags.subdomain);
46
+ }
47
+ function copyTree(src, dst, flags) {
48
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
49
+ const srcPath = path.join(src, entry.name);
50
+ const baseName = entry.name.endsWith(".tmpl") ? entry.name.slice(0, -".tmpl".length) : entry.name;
51
+ const dstPath = path.join(dst, replaceVars(baseName, flags));
52
+ if (entry.isDirectory()) {
53
+ fs.mkdirSync(dstPath, { recursive: true });
54
+ copyTree(srcPath, dstPath, flags);
55
+ } else {
56
+ const raw = fs.readFileSync(srcPath, "utf8");
57
+ fs.writeFileSync(dstPath, replaceVars(raw, flags), "utf8");
58
+ }
59
+ }
60
+ }
61
+ function main() {
62
+ const flags = parseArgs(process.argv);
63
+ const target = path.resolve(process.cwd(), flags.name);
64
+ ensureEmptyDir(target);
65
+ const templates = templatesDir();
66
+ if (!fs.existsSync(templates)) {
67
+ console.error(`Templates directory missing at ${templates}. Re-install the CLI.`);
68
+ process.exit(1);
69
+ }
70
+ copyTree(templates, target, flags);
71
+ console.log(`Created spoke "${flags.name}" at ${target}`);
72
+ console.log("");
73
+ console.log("Next steps:");
74
+ console.log(` cd ${flags.name}`);
75
+ console.log(" cp .env.example .env # fill in tenant values");
76
+ console.log(" pnpm install");
77
+ console.log(" pnpm dev");
78
+ }
79
+ main();
80
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts"],"sourcesContent":["import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport process from \"node:process\";\n\n// =============================================================================\n// create-spoke -- scaffold a new product subdomain for an augint tenant.\n//\n// Usage:\n// pnpm dlx @augmenting-integrations/create-spoke <name> [--slug=foo] [--subdomain=bar]\n//\n// Writes a minimal but deployable Next.js 16 app to ./<name>/ with:\n// - package.json (latest @augmenting-integrations/* peer deps)\n// - tsconfig.json + next.config.ts + .gitignore + .env.example\n// - src/proxy.ts (default-export auth proxy)\n// - src/lib/{auth,db,users}.ts wired to loadTenantConfig + canonical factory\n// - src/app/{layout,page,Providers}.tsx with TenantBootScript + TenantProvider\n// - src/app/api/auth/[...nextauth]/route.ts + /api/auth/me/route.ts\n// - src/app/api/billing/* (route re-exports of the library factory)\n// - prisma/schema.prisma with the canonical fragments\n// - prisma/seed.mjs with the STAGE gate\n// - README.md explaining \"fill in .env, run pnpm install, pnpm dev\"\n//\n// Out of scope (manual follow-up, documented in the generated README):\n// - template.yaml (per-tenant infra varies; we ship a stub)\n// - GitHub workflow (we ship a minimal deploy.yml stub)\n// - DNS / Cognito / Aurora provisioning (tenant infra repo's job)\n// =============================================================================\n\ntype Flags = {\n name: string;\n slug: string;\n subdomain: string;\n};\n\nfunction parseArgs(argv: string[]): Flags {\n const args = argv.slice(2);\n let name: string | undefined;\n let slug: string | undefined;\n let subdomain: string | undefined;\n for (const a of args) {\n if (a.startsWith(\"--slug=\")) slug = a.slice(\"--slug=\".length);\n else if (a.startsWith(\"--subdomain=\")) subdomain = a.slice(\"--subdomain=\".length);\n else if (!a.startsWith(\"--\")) name = a;\n }\n if (!name) {\n console.error(\"Usage: create-spoke <name> [--slug=foo] [--subdomain=bar]\");\n process.exit(1);\n }\n // Defaults: slug = name lowercased, subdomain = first segment of slug.\n const safeName = name.toLowerCase().replace(/[^a-z0-9-]/g, \"-\");\n return {\n name,\n slug: slug ?? safeName,\n subdomain: subdomain ?? safeName.split(\"-\")[0]!,\n };\n}\n\nfunction templatesDir(): string {\n // dist/cli.js lives at <pkg>/dist/cli.js; templates at <pkg>/templates.\n const here = fileURLToPath(import.meta.url);\n return path.resolve(path.dirname(here), \"..\", \"templates\");\n}\n\nfunction ensureEmptyDir(target: string): void {\n if (fs.existsSync(target)) {\n const entries = fs.readdirSync(target);\n if (entries.length > 0) {\n console.error(`Refusing to write into non-empty directory: ${target}`);\n process.exit(1);\n }\n } else {\n fs.mkdirSync(target, { recursive: true });\n }\n}\n\nfunction replaceVars(content: string, flags: Flags): string {\n return content\n .replace(/__SPOKE_NAME__/g, flags.name)\n .replace(/__SPOKE_SLUG__/g, flags.slug)\n .replace(/__SPOKE_SUBDOMAIN__/g, flags.subdomain);\n}\n\nfunction copyTree(src: string, dst: string, flags: Flags): void {\n for (const entry of fs.readdirSync(src, { withFileTypes: true })) {\n const srcPath = path.join(src, entry.name);\n // Strip the \".tmpl\" suffix from file names so the templates can ship as\n // .tmpl in npm (avoids npm's package-content guards rejecting bare\n // package.json / .env inside the templates dir).\n const baseName = entry.name.endsWith(\".tmpl\")\n ? entry.name.slice(0, -\".tmpl\".length)\n : entry.name;\n const dstPath = path.join(dst, replaceVars(baseName, flags));\n if (entry.isDirectory()) {\n fs.mkdirSync(dstPath, { recursive: true });\n copyTree(srcPath, dstPath, flags);\n } else {\n const raw = fs.readFileSync(srcPath, \"utf8\");\n fs.writeFileSync(dstPath, replaceVars(raw, flags), \"utf8\");\n }\n }\n}\n\nfunction main(): void {\n const flags = parseArgs(process.argv);\n const target = path.resolve(process.cwd(), flags.name);\n ensureEmptyDir(target);\n const templates = templatesDir();\n if (!fs.existsSync(templates)) {\n console.error(`Templates directory missing at ${templates}. Re-install the CLI.`);\n process.exit(1);\n }\n copyTree(templates, target, flags);\n console.log(`Created spoke \"${flags.name}\" at ${target}`);\n console.log(\"\");\n console.log(\"Next steps:\");\n console.log(` cd ${flags.name}`);\n console.log(\" cp .env.example .env # fill in tenant values\");\n console.log(\" pnpm install\");\n console.log(\" pnpm dev\");\n}\n\nmain();\n"],"mappings":";;;AAAA,YAAY,QAAQ;AACpB,YAAY,UAAU;AACtB,SAAS,qBAAqB;AAC9B,OAAO,aAAa;AAgCpB,SAAS,UAAU,MAAuB;AACxC,QAAM,OAAO,KAAK,MAAM,CAAC;AACzB,MAAI;AACJ,MAAI;AACJ,MAAI;AACJ,aAAW,KAAK,MAAM;AACpB,QAAI,EAAE,WAAW,SAAS,EAAG,QAAO,EAAE,MAAM,UAAU,MAAM;AAAA,aACnD,EAAE,WAAW,cAAc,EAAG,aAAY,EAAE,MAAM,eAAe,MAAM;AAAA,aACvE,CAAC,EAAE,WAAW,IAAI,EAAG,QAAO;AAAA,EACvC;AACA,MAAI,CAAC,MAAM;AACT,YAAQ,MAAM,2DAA2D;AACzE,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,QAAM,WAAW,KAAK,YAAY,EAAE,QAAQ,eAAe,GAAG;AAC9D,SAAO;AAAA,IACL;AAAA,IACA,MAAM,QAAQ;AAAA,IACd,WAAW,aAAa,SAAS,MAAM,GAAG,EAAE,CAAC;AAAA,EAC/C;AACF;AAEA,SAAS,eAAuB;AAE9B,QAAM,OAAO,cAAc,YAAY,GAAG;AAC1C,SAAY,aAAa,aAAQ,IAAI,GAAG,MAAM,WAAW;AAC3D;AAEA,SAAS,eAAe,QAAsB;AAC5C,MAAO,cAAW,MAAM,GAAG;AACzB,UAAM,UAAa,eAAY,MAAM;AACrC,QAAI,QAAQ,SAAS,GAAG;AACtB,cAAQ,MAAM,+CAA+C,MAAM,EAAE;AACrE,cAAQ,KAAK,CAAC;AAAA,IAChB;AAAA,EACF,OAAO;AACL,IAAG,aAAU,QAAQ,EAAE,WAAW,KAAK,CAAC;AAAA,EAC1C;AACF;AAEA,SAAS,YAAY,SAAiB,OAAsB;AAC1D,SAAO,QACJ,QAAQ,mBAAmB,MAAM,IAAI,EACrC,QAAQ,mBAAmB,MAAM,IAAI,EACrC,QAAQ,wBAAwB,MAAM,SAAS;AACpD;AAEA,SAAS,SAAS,KAAa,KAAa,OAAoB;AAC9D,aAAW,SAAY,eAAY,KAAK,EAAE,eAAe,KAAK,CAAC,GAAG;AAChE,UAAM,UAAe,UAAK,KAAK,MAAM,IAAI;AAIzC,UAAM,WAAW,MAAM,KAAK,SAAS,OAAO,IACxC,MAAM,KAAK,MAAM,GAAG,CAAC,QAAQ,MAAM,IACnC,MAAM;AACV,UAAM,UAAe,UAAK,KAAK,YAAY,UAAU,KAAK,CAAC;AAC3D,QAAI,MAAM,YAAY,GAAG;AACvB,MAAG,aAAU,SAAS,EAAE,WAAW,KAAK,CAAC;AACzC,eAAS,SAAS,SAAS,KAAK;AAAA,IAClC,OAAO;AACL,YAAM,MAAS,gBAAa,SAAS,MAAM;AAC3C,MAAG,iBAAc,SAAS,YAAY,KAAK,KAAK,GAAG,MAAM;AAAA,IAC3D;AAAA,EACF;AACF;AAEA,SAAS,OAAa;AACpB,QAAM,QAAQ,UAAU,QAAQ,IAAI;AACpC,QAAM,SAAc,aAAQ,QAAQ,IAAI,GAAG,MAAM,IAAI;AACrD,iBAAe,MAAM;AACrB,QAAM,YAAY,aAAa;AAC/B,MAAI,CAAI,cAAW,SAAS,GAAG;AAC7B,YAAQ,MAAM,kCAAkC,SAAS,uBAAuB;AAChF,YAAQ,KAAK,CAAC;AAAA,EAChB;AACA,WAAS,WAAW,QAAQ,KAAK;AACjC,UAAQ,IAAI,kBAAkB,MAAM,IAAI,QAAQ,MAAM,EAAE;AACxD,UAAQ,IAAI,EAAE;AACd,UAAQ,IAAI,aAAa;AACzB,UAAQ,IAAI,QAAQ,MAAM,IAAI,EAAE;AAChC,UAAQ,IAAI,iDAAiD;AAC7D,UAAQ,IAAI,gBAAgB;AAC5B,UAAQ,IAAI,YAAY;AAC1B;AAEA,KAAK;","names":[]}
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@augmenting-integrations/create-spoke",
3
+ "version": "8.3.0",
4
+ "description": "Scaffold a new product subdomain (spoke) for an augint-* tenant. Generates a Next 16 + Auth.js v5 app with TenantConfig wired up, library-owned route handlers, a Prisma canonical schema fragment, and a deployable template.yaml + GitHub workflow. Single command: pnpm dlx @augmenting-integrations/create-spoke my-spoke.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "bin": {
11
+ "create-spoke": "./dist/cli.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "templates",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup",
20
+ "clean": "rm -rf dist",
21
+ "test": "vitest run --passWithNoTests"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.0.0",
25
+ "tsup": "^8.3.5",
26
+ "typescript": "^5.7.2",
27
+ "vitest": "^4.1.5"
28
+ }
29
+ }
@@ -0,0 +1,25 @@
1
+ # Tenant identity (everything else flows from these)
2
+ APP_DOMAIN=__SPOKE_SUBDOMAIN__.tenant.example.com
3
+ APP_SLUG=__SPOKE_SLUG__
4
+ APEX_DOMAIN=tenant.example.com
5
+ AUTH_COOKIE_DOMAIN=.tenant.example.com
6
+ AUTH_ALLOWED_PARENT_DOMAIN=.tenant.example.com
7
+
8
+ # AWS resources (typically pulled from SSM in deployed Lambda; local dev only)
9
+ AWS_REGION=us-east-1
10
+ AUTH_SECRET_ARN=
11
+ AUTH_COGNITO_ID=
12
+ AUTH_COGNITO_ISSUER=
13
+ DB_SECRET_ARN=
14
+ DB_HOST=
15
+ DB_NAME=
16
+ APP_REGISTRY_TABLE=
17
+ STRIPE_SECRET_ARN=
18
+ STRIPE_WEBHOOK_SECRET_ARN=
19
+
20
+ # Comma-separated emails auto-promoted to role=admin on first sign-in
21
+ ADMIN_EMAILS=
22
+
23
+ # Local-dev fallback for testing without Secrets Manager
24
+ AUTH_SECRET=dev-only-fallback-not-for-prod
25
+ NODE_ENV=development
@@ -0,0 +1,9 @@
1
+ node_modules
2
+ .next
3
+ .env
4
+ .env.local
5
+ *.log
6
+ .DS_Store
7
+ prisma/migrations
8
+ prisma/dev.db
9
+ .vercel
@@ -0,0 +1,47 @@
1
+ # __SPOKE_NAME__
2
+
3
+ Generated by `@augmenting-integrations/create-spoke`. This is a Next.js 16
4
+ product subdomain ("spoke") inside an augint tenant. Auth, billing, and
5
+ admin route handlers come from `@augmenting-integrations/*` libraries —
6
+ your job is the product surface in `src/app/` plus any product-specific
7
+ Prisma models.
8
+
9
+ ## Local dev
10
+
11
+ ```bash
12
+ cp .env.example .env
13
+ # fill in the tenant identity values; AWS creds may stay blank for local dev
14
+ pnpm install
15
+ pnpm dev
16
+ ```
17
+
18
+ You should see `Not signed in.` on `/`. Sign-in flow requires a working
19
+ apex (the apex app is the only OAuth callback in the tenant — it sets the
20
+ shared cookie and redirects you back).
21
+
22
+ ## Per-tenant adoption checklist
23
+
24
+ 1. **Tenant identity** in `.env` — replace `tenant.example.com` with your real apex.
25
+ 2. **AWS resources** — `AUTH_SECRET_ARN`, `DB_SECRET_ARN`, Cognito ids, etc. come from your tenant infra stack (the same SSM params the apex reads).
26
+ 3. **Prisma** — `prisma/schema.prisma` ships with canonical User/Invitation/PaymentMethod/CreditTransaction. Add your product models below the marker comment.
27
+ 4. **Brand** — drop a `config/brand.json` in if you want this spoke's name in the chrome.
28
+ 5. **App registry** — register this spoke in your apex's DynamoDB app registry (slug `__SPOKE_SLUG__`, subdomain `__SPOKE_SUBDOMAIN__`).
29
+
30
+ ## Deploy
31
+
32
+ This scaffold ships only the application code. Infrastructure (CloudFront
33
+ distribution, Lambda runtime, Aurora cluster + RDS Proxy, migrate-runner
34
+ Lambda) is per-tenant and varies; copy the `template.yaml` from your
35
+ tenant's other spokes (e.g. the example leads-marketplace) and edit the
36
+ slug + subdomain.
37
+
38
+ ## What you can NOT customize without diverging from the library
39
+
40
+ - Canonical schema fields (`User.credit_balance`, `CreditTransaction.type`,
41
+ `PaymentMethod.stripe_payment_method_id`, etc.). The library's billing
42
+ factory assumes these.
43
+ - `/api/billing/*` URL paths (the client widgets bake them in).
44
+ - Auth.js v5 + Cognito as the provider chain.
45
+
46
+ For product-specific routes (`/api/leads`, `/api/quotes`, `/api/products`),
47
+ add them under `src/app/api/` — they're yours to design.
@@ -0,0 +1,11 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ output: "standalone",
5
+ // Pin trace root to this app. Next 16 otherwise detects a parent monorepo
6
+ // and emits .next/standalone/<pkg>/server.js. Our run.sh + Makefile assume
7
+ // a flat .next/standalone/server.js root.
8
+ outputFileTracingRoot: process.cwd(),
9
+ };
10
+
11
+ export default nextConfig;
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "__SPOKE_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "next dev",
8
+ "build": "next build",
9
+ "start": "next start",
10
+ "lint": "eslint .",
11
+ "type-check": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@augmenting-integrations/auth": "^8.0.0",
15
+ "@augmenting-integrations/aws": "^8.0.0",
16
+ "@augmenting-integrations/billing": "^8.0.0",
17
+ "@augmenting-integrations/brand": "^8.0.0",
18
+ "@augmenting-integrations/db-secret-loader": "^8.0.0",
19
+ "@augmenting-integrations/themes": "^8.0.0",
20
+ "@augmenting-integrations/ui": "^8.0.0",
21
+ "@prisma/client": "^6.0.0",
22
+ "next": "^16.2.5",
23
+ "next-auth": "^5.0.0-beta.31",
24
+ "react": "^19.2.0",
25
+ "react-dom": "^19.2.0",
26
+ "zod": "^4.1.7"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.0.0",
30
+ "@types/react": "^19.0.0",
31
+ "@types/react-dom": "^19.0.0",
32
+ "prisma": "^6.0.0",
33
+ "typescript": "^5.7.2"
34
+ }
35
+ }
@@ -0,0 +1,97 @@
1
+ // Canonical schema fragment + your product models below.
2
+ // The User / Invitation / PaymentMethod / CreditTransaction / ActivityLog
3
+ // shapes are what @augmenting-integrations/auth + /billing assume.
4
+ // Modify ONLY the product models section.
5
+
6
+ generator client {
7
+ provider = "prisma-client-js"
8
+ binaryTargets = ["native", "rhel-openssl-3.0.x"]
9
+ }
10
+
11
+ datasource db {
12
+ provider = "postgresql"
13
+ url = env("DATABASE_URL")
14
+ }
15
+
16
+ // ---------------- Canonical: required by @augmenting-integrations ----------------
17
+
18
+ model User {
19
+ id BigInt @id @default(autoincrement())
20
+ email String @unique
21
+ name String
22
+ role String @default("member")
23
+ is_active Boolean @default(true)
24
+ must_change_password Boolean @default(false)
25
+ parent_id BigInt?
26
+ credit_balance Decimal @default(0) @db.Decimal(12, 2)
27
+ password_hash String @default("")
28
+ zapier_api_key String?
29
+ stripe_customer_id String? @unique
30
+ stripe_default_payment_method String?
31
+ stripe_default_payment_method_display String?
32
+ auto_recharge_enabled Boolean @default(false)
33
+ auto_recharge_threshold Decimal? @db.Decimal(12, 2)
34
+ auto_recharge_amount Decimal? @db.Decimal(12, 2)
35
+ created_at DateTime @default(now())
36
+ updated_at DateTime @updatedAt
37
+ parent User? @relation("UserHierarchy", fields: [parent_id], references: [id])
38
+ children User[] @relation("UserHierarchy")
39
+ payment_methods PaymentMethod[]
40
+ credit_transactions CreditTransaction[]
41
+ activity_log ActivityLog[]
42
+ invitations_sent Invitation[] @relation("InviterRelation")
43
+ }
44
+
45
+ model Invitation {
46
+ id BigInt @id @default(autoincrement())
47
+ email String
48
+ token String @unique
49
+ inviter_id BigInt
50
+ intended_role String
51
+ expires_at DateTime
52
+ accepted_at DateTime?
53
+ accepted_by_user_id BigInt?
54
+ created_at DateTime @default(now())
55
+ inviter User @relation("InviterRelation", fields: [inviter_id], references: [id])
56
+ }
57
+
58
+ model PaymentMethod {
59
+ id BigInt @id @default(autoincrement())
60
+ user_id BigInt
61
+ stripe_payment_method_id String @unique
62
+ brand String
63
+ last4 String
64
+ exp_month Int
65
+ exp_year Int
66
+ is_default Boolean @default(false)
67
+ created_at DateTime @default(now())
68
+ user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
69
+ }
70
+
71
+ model CreditTransaction {
72
+ id BigInt @id @default(autoincrement())
73
+ user_id BigInt
74
+ type String
75
+ amount Decimal @db.Decimal(12, 2)
76
+ description String?
77
+ stripe_payment_intent_id String? @unique
78
+ payment_method_display String?
79
+ created_at DateTime @default(now())
80
+ user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
81
+ }
82
+
83
+ model ActivityLog {
84
+ id BigInt @id @default(autoincrement())
85
+ user_id BigInt
86
+ action String
87
+ metadata Json?
88
+ created_at DateTime @default(now())
89
+ user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
90
+ }
91
+
92
+ // ---------------- Product-specific models (yours to design) ----------------
93
+
94
+ // model __SPOKE_SLUG___Item {
95
+ // id BigInt @id @default(autoincrement())
96
+ // ...
97
+ // }
@@ -0,0 +1,41 @@
1
+ import { PrismaClient } from "@prisma/client";
2
+
3
+ const prisma = new PrismaClient();
4
+
5
+ async function main() {
6
+ // Refuse to seed outside staging. Without this gate, a CI run with
7
+ // STAGE=prod could fire seeds into a real customer database. Pair with
8
+ // the migrate-runner Lambda which always passes STAGE in env.
9
+ if (process.env.STAGE && process.env.STAGE !== "staging") {
10
+ console.log(`Seed: refusing to run in stage=${process.env.STAGE}`);
11
+ return;
12
+ }
13
+ const adminEmails = (process.env.ADMIN_EMAILS ?? "")
14
+ .split(",")
15
+ .map((s) => s.trim().toLowerCase())
16
+ .filter(Boolean);
17
+
18
+ for (const email of adminEmails) {
19
+ await prisma.user.upsert({
20
+ where: { email },
21
+ update: { role: "admin" },
22
+ create: {
23
+ email,
24
+ name: email.split("@")[0],
25
+ role: "admin",
26
+ is_active: true,
27
+ credit_balance: 500,
28
+ },
29
+ });
30
+ console.log(`Seeded admin: ${email}`);
31
+ }
32
+ }
33
+
34
+ main()
35
+ .catch((e) => {
36
+ console.error(e);
37
+ process.exit(1);
38
+ })
39
+ .finally(async () => {
40
+ await prisma.$disconnect();
41
+ });
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import type { Session } from "next-auth";
5
+ import { SessionProvider } from "@augmenting-integrations/ui";
6
+ import {
7
+ TenantProvider,
8
+ type TenantPublicConfig,
9
+ } from "@augmenting-integrations/auth/client";
10
+
11
+ export function Providers({
12
+ children,
13
+ session,
14
+ tenant,
15
+ }: {
16
+ children: React.ReactNode;
17
+ session: Session | null;
18
+ tenant: TenantPublicConfig;
19
+ }) {
20
+ return (
21
+ <TenantProvider tenant={tenant}>
22
+ <SessionProvider session={session}>{children}</SessionProvider>
23
+ </TenantProvider>
24
+ );
25
+ }
@@ -0,0 +1,13 @@
1
+ import { createImpersonateHandlers } from "@augmenting-integrations/auth/server";
2
+ import { auth } from "@/lib/auth";
3
+ import { getDb } from "@/lib/db";
4
+ import { getOrCreateAppUser } from "@/lib/users";
5
+
6
+ export const runtime = "nodejs";
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export const { POST, DELETE } = createImpersonateHandlers({
10
+ auth,
11
+ getDb: getDb as never,
12
+ getOrCreateAppUser: getOrCreateAppUser as never,
13
+ });
@@ -0,0 +1,5 @@
1
+ // Spokes don't actually serve /api/auth/callback (the apex does), but they
2
+ // need this file present so internal Auth.js calls (signIn, signOut) work.
3
+ import { handlers } from "@/lib/auth";
4
+
5
+ export const { GET, POST } = handlers;
@@ -0,0 +1,13 @@
1
+ import { createMeHandler } from "@augmenting-integrations/auth/server";
2
+ import { auth } from "@/lib/auth";
3
+ import { getDb } from "@/lib/db";
4
+ import { getOrCreateAppUser } from "@/lib/users";
5
+
6
+ export const runtime = "nodejs";
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export const { GET } = createMeHandler({
10
+ auth,
11
+ getDb: getDb as never,
12
+ getOrCreateAppUser: getOrCreateAppUser as never,
13
+ });
@@ -0,0 +1,6 @@
1
+ import { billingHandlers } from "@/lib/billing";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export const { PATCH } = billingHandlers.autoRecharge;
@@ -0,0 +1,6 @@
1
+ import { billingHandlers } from "@/lib/billing";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export const { GET } = billingHandlers.balance;
@@ -0,0 +1,6 @@
1
+ import { billingHandlers } from "@/lib/billing";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export const { POST } = billingHandlers.paymentIntent;
@@ -0,0 +1,6 @@
1
+ import { billingHandlers } from "@/lib/billing";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export const { PATCH } = billingHandlers.paymentMethodDefault;
@@ -0,0 +1,6 @@
1
+ import { billingHandlers } from "@/lib/billing";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export const { DELETE } = billingHandlers.paymentMethodById;
@@ -0,0 +1,6 @@
1
+ import { billingHandlers } from "@/lib/billing";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export const { GET } = billingHandlers.paymentMethods;
@@ -0,0 +1,6 @@
1
+ import { billingHandlers } from "@/lib/billing";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export const { GET } = billingHandlers.aggregate;
@@ -0,0 +1,6 @@
1
+ import { billingHandlers } from "@/lib/billing";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export const { POST } = billingHandlers.setupIntent;
@@ -0,0 +1,6 @@
1
+ import { billingHandlers } from "@/lib/billing";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export const { POST } = billingHandlers.webhook;
@@ -0,0 +1,6 @@
1
+ import { billingHandlers } from "@/lib/billing";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export const { GET } = billingHandlers.transactions;
@@ -0,0 +1,7 @@
1
+ import { createInvitationHandlers } from "@augmenting-integrations/auth/server";
2
+ import { getDb } from "@/lib/db";
3
+
4
+ export const runtime = "nodejs";
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export const { GET, POST } = createInvitationHandlers({ getDb: getDb as never });
@@ -0,0 +1,44 @@
1
+ @import "tailwindcss";
2
+
3
+ @source "../../node_modules/@augmenting-integrations/ui/dist";
4
+ @source "../../node_modules/@augmenting-integrations/auth/dist";
5
+ @source "../../node_modules/@augmenting-integrations/billing/dist";
6
+ @source "../../node_modules/@augmenting-integrations/brand/dist";
7
+
8
+ @custom-variant dark (&:where(.dark, .dark *));
9
+
10
+ @theme inline {
11
+ --color-background: var(--background);
12
+ --color-foreground: var(--foreground);
13
+ --color-primary: var(--primary);
14
+ --color-primary-foreground: var(--primary-foreground);
15
+ --color-secondary: var(--secondary);
16
+ --color-secondary-foreground: var(--secondary-foreground);
17
+ --color-muted: var(--muted);
18
+ --color-muted-foreground: var(--muted-foreground);
19
+ --color-accent: var(--accent);
20
+ --color-accent-foreground: var(--accent-foreground);
21
+ --color-destructive: var(--destructive);
22
+ --color-destructive-foreground: var(--destructive-foreground);
23
+ --color-success: var(--success);
24
+ --color-success-foreground: var(--success-foreground);
25
+ --color-warning: var(--warning);
26
+ --color-warning-foreground: var(--warning-foreground);
27
+ --color-info: var(--info);
28
+ --color-info-foreground: var(--info-foreground);
29
+ --color-border: var(--border);
30
+ }
31
+
32
+ :root {
33
+ --background: oklch(0.99 0 0);
34
+ --foreground: oklch(0.15 0 0);
35
+ --primary: oklch(0.55 0.18 250);
36
+ --primary-foreground: oklch(0.98 0 0);
37
+ --border: oklch(0.9 0 0);
38
+ }
39
+
40
+ .dark {
41
+ --background: oklch(0.12 0.02 240);
42
+ --foreground: oklch(0.95 0 0);
43
+ --border: oklch(0.25 0 0);
44
+ }
@@ -0,0 +1,42 @@
1
+ import type { Metadata } from "next";
2
+ import { cookies } from "next/headers";
3
+ import {
4
+ THEME_COOKIE_KEY,
5
+ THEME_VARIANT_COOKIE_KEY,
6
+ } from "@augmenting-integrations/themes";
7
+ import { ThemeBootScript } from "@augmenting-integrations/ui";
8
+ import { TenantBootScript, publicSubset } from "@augmenting-integrations/auth/server";
9
+ import { auth, tenant } from "@/lib/auth";
10
+ import { Providers } from "./Providers";
11
+ import "./globals.css";
12
+
13
+ export const metadata: Metadata = {
14
+ title: { default: "__SPOKE_NAME__", template: "%s — __SPOKE_NAME__" },
15
+ };
16
+
17
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
18
+ const [session, cookieStore] = await Promise.all([auth(), cookies()]);
19
+ const themeName = cookieStore.get(THEME_COOKIE_KEY)?.value ?? "default";
20
+ const variantCookie = cookieStore.get(THEME_VARIANT_COOKIE_KEY)?.value;
21
+ const variant: "dark" | "light" =
22
+ variantCookie === "dark" || variantCookie === "light" ? variantCookie : "light";
23
+ const publicTenant = publicSubset(tenant);
24
+ return (
25
+ <html
26
+ lang="en"
27
+ data-theme={themeName}
28
+ className={variant === "dark" ? "dark" : undefined}
29
+ suppressHydrationWarning
30
+ >
31
+ <head>
32
+ <TenantBootScript config={publicTenant} />
33
+ <ThemeBootScript />
34
+ </head>
35
+ <body>
36
+ <Providers session={session} tenant={publicTenant}>
37
+ {children}
38
+ </Providers>
39
+ </body>
40
+ </html>
41
+ );
42
+ }
@@ -0,0 +1,36 @@
1
+ import { auth, signOut } from "@/lib/auth";
2
+ import { getOrCreateAppUser } from "@/lib/users";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export default async function Home() {
7
+ const session = await auth();
8
+ const user = session?.user ? await getOrCreateAppUser(session) : null;
9
+ return (
10
+ <main style={{ padding: "2rem", fontFamily: "system-ui" }}>
11
+ <h1>__SPOKE_NAME__</h1>
12
+ {user ? (
13
+ <>
14
+ <p>
15
+ Hello, <strong>{user.name}</strong> ({user.email}) — role:{" "}
16
+ <code>{user.role}</code>
17
+ </p>
18
+ <p>
19
+ Credit balance:{" "}
20
+ <strong>${Number(user.credit_balance).toFixed(2)}</strong>
21
+ </p>
22
+ <form
23
+ action={async () => {
24
+ "use server";
25
+ await signOut();
26
+ }}
27
+ >
28
+ <button type="submit">Sign out</button>
29
+ </form>
30
+ </>
31
+ ) : (
32
+ <p>Not signed in.</p>
33
+ )}
34
+ </main>
35
+ );
36
+ }
@@ -0,0 +1,15 @@
1
+ import { createAuth, loadTenantConfig } from "@augmenting-integrations/auth/server";
2
+ import { getSecret } from "@augmenting-integrations/aws/server";
3
+
4
+ // Single tenant configuration source.
5
+ export const tenant = loadTenantConfig({ role: "spoke" });
6
+
7
+ // AUTH_SECRET lives in Secrets Manager in prod; dev falls back to a placeholder.
8
+ const authSecret =
9
+ (await getSecret(tenant.authSecretArn)) ?? "dev-only-fallback-not-for-prod";
10
+
11
+ export const { handlers, auth, signIn, signOut } = createAuth({
12
+ tenant,
13
+ authedRoutePrefixes: ["/"],
14
+ authSecret,
15
+ });
@@ -0,0 +1,11 @@
1
+ import { createBillingHandlers } from "@augmenting-integrations/billing/server";
2
+ import { auth } from "./auth.js";
3
+ import { getDb } from "./db.js";
4
+ import { getOrCreateAppUser } from "./users.js";
5
+
6
+ // One factory call. Each /api/billing/* route file re-exports from this.
7
+ export const billingHandlers = createBillingHandlers({
8
+ auth,
9
+ getDb: getDb as never,
10
+ getOrCreateAppUser: getOrCreateAppUser as never,
11
+ });
@@ -0,0 +1,21 @@
1
+ import { PrismaClient } from "@prisma/client";
2
+ import { loadDatabaseUrl } from "@augmenting-integrations/db-secret-loader";
3
+ import { tenant } from "./auth.js";
4
+
5
+ // Lazy singleton. Lambda container reuse caches the client.
6
+ let _db: Promise<PrismaClient> | undefined;
7
+
8
+ export async function getDb(): Promise<PrismaClient> {
9
+ if (!_db) {
10
+ _db = (async () => {
11
+ const url = await loadDatabaseUrl({
12
+ secretArn: tenant.dbSecretArn,
13
+ host: tenant.dbHost,
14
+ dbName: tenant.dbName,
15
+ fallbackUrl: process.env.DATABASE_URL,
16
+ });
17
+ return new PrismaClient({ datasources: { db: { url } } });
18
+ })();
19
+ }
20
+ return _db;
21
+ }
@@ -0,0 +1,21 @@
1
+ import { createGetOrCreateAppUser } from "@augmenting-integrations/auth/server";
2
+ import { getDb } from "./db.js";
3
+
4
+ // Default $500 for admins/owners, $100 for everyone else. Tune per spoke.
5
+ function computeCreditBalance(role: string): number {
6
+ if (role === "admin" || role === "owner") return 500;
7
+ return 100;
8
+ }
9
+
10
+ export const getOrCreateAppUser = createGetOrCreateAppUser({
11
+ db: getDb,
12
+ defaultRole: "member",
13
+ computeCreditBalance,
14
+ adminEmails: (process.env.ADMIN_EMAILS ?? "")
15
+ .split(",")
16
+ .map((s) => s.trim().toLowerCase())
17
+ .filter(Boolean),
18
+ extraCreateFields: { is_active: true, must_change_password: false },
19
+ });
20
+
21
+ export type { AppUser } from "@augmenting-integrations/auth/server";
@@ -0,0 +1,10 @@
1
+ // Next 16: this file must be at src/proxy.ts as a DEFAULT export. The
2
+ // @augmenting-integrations/auth package's createAuth() returns a value
3
+ // containing the proxy; we re-export it directly.
4
+ import { auth } from "./lib/auth.js";
5
+
6
+ export default auth;
7
+
8
+ export const config = {
9
+ matcher: ["/((?!_next/static|_next/image|favicon.ico|api/auth/).*)"],
10
+ };
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": false,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": { "@/*": ["./src/*"] }
18
+ },
19
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
20
+ "exclude": ["node_modules"]
21
+ }