showpane 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +14 -1
  2. package/bundle/meta/scaffold-manifest.json +73 -0
  3. package/bundle/scaffold/VERSION +1 -0
  4. package/bundle/scaffold/__dot__env.example +24 -0
  5. package/bundle/scaffold/__dot__gitignore +41 -0
  6. package/bundle/scaffold/docker/Caddyfile +3 -0
  7. package/bundle/scaffold/docker/Dockerfile +30 -0
  8. package/bundle/scaffold/docker-compose.yml +53 -0
  9. package/bundle/scaffold/next.config.ts +20 -0
  10. package/bundle/scaffold/package-lock.json +5843 -0
  11. package/bundle/scaffold/package.json +42 -0
  12. package/bundle/scaffold/postcss.config.js +6 -0
  13. package/bundle/scaffold/prisma/migrations/20260408000000_init/migration.sql +143 -0
  14. package/bundle/scaffold/prisma/migrations/20260408010000_add_visitor_tracking/migration.sql +6 -0
  15. package/bundle/scaffold/prisma/migrations/20260409040000_add_portal_file_checksum/migration.sql +2 -0
  16. package/bundle/scaffold/prisma/migrations/migration_lock.toml +3 -0
  17. package/bundle/scaffold/prisma/schema.local.prisma +131 -0
  18. package/bundle/scaffold/prisma/schema.prisma +128 -0
  19. package/bundle/scaffold/prisma/seed.ts +49 -0
  20. package/bundle/scaffold/public/example-avatar.svg +4 -0
  21. package/bundle/scaffold/public/example-logo.svg +4 -0
  22. package/bundle/scaffold/public/robots.txt +2 -0
  23. package/bundle/scaffold/scripts/backup.sh +19 -0
  24. package/bundle/scaffold/scripts/e2e-verify.sh +487 -0
  25. package/bundle/scaffold/scripts/prisma-db-push.mjs +7 -0
  26. package/bundle/scaffold/scripts/prisma-generate.mjs +3 -0
  27. package/bundle/scaffold/scripts/prisma-schema.mjs +74 -0
  28. package/bundle/scaffold/scripts/restore.sh +31 -0
  29. package/bundle/scaffold/src/__tests__/client-portals.test.ts +80 -0
  30. package/bundle/scaffold/src/__tests__/portal-contracts.test.ts +32 -0
  31. package/bundle/scaffold/src/app/(portal)/client/[slug]/page.tsx +79 -0
  32. package/bundle/scaffold/src/app/(portal)/client/[slug]/s/[token]/route.ts +22 -0
  33. package/bundle/scaffold/src/app/(portal)/client/example/example-client.tsx +372 -0
  34. package/bundle/scaffold/src/app/(portal)/client/example/page.tsx +5 -0
  35. package/bundle/scaffold/src/app/(portal)/client/layout.tsx +7 -0
  36. package/bundle/scaffold/src/app/(portal)/client/page.tsx +18 -0
  37. package/bundle/scaffold/src/app/api/client-auth/route.ts +82 -0
  38. package/bundle/scaffold/src/app/api/client-auth/share/route.ts +30 -0
  39. package/bundle/scaffold/src/app/api/client-events/route.ts +87 -0
  40. package/bundle/scaffold/src/app/api/client-files/[...path]/route.ts +80 -0
  41. package/bundle/scaffold/src/app/api/client-files/client-upload/route.ts +118 -0
  42. package/bundle/scaffold/src/app/api/client-files/route.ts +37 -0
  43. package/bundle/scaffold/src/app/api/client-files/upload/route.ts +131 -0
  44. package/bundle/scaffold/src/app/api/health/route.ts +19 -0
  45. package/bundle/scaffold/src/app/globals.css +7 -0
  46. package/bundle/scaffold/src/app/layout.tsx +25 -0
  47. package/bundle/scaffold/src/app/page.tsx +171 -0
  48. package/bundle/scaffold/src/components/portal-login.tsx +169 -0
  49. package/bundle/scaffold/src/components/portal-shell.tsx +373 -0
  50. package/bundle/scaffold/src/lib/abuse-controls.ts +43 -0
  51. package/bundle/scaffold/src/lib/branding.ts +50 -0
  52. package/bundle/scaffold/src/lib/client-auth.ts +98 -0
  53. package/bundle/scaffold/src/lib/client-portals.ts +134 -0
  54. package/bundle/scaffold/src/lib/control-plane.ts +100 -0
  55. package/bundle/scaffold/src/lib/db.ts +7 -0
  56. package/bundle/scaffold/src/lib/files.ts +124 -0
  57. package/bundle/scaffold/src/lib/load-app-env.ts +42 -0
  58. package/bundle/scaffold/src/lib/portal-contracts.ts +69 -0
  59. package/bundle/scaffold/src/lib/prisma-client.ts +5 -0
  60. package/bundle/scaffold/src/lib/runtime-state.ts +69 -0
  61. package/bundle/scaffold/src/lib/storage.ts +204 -0
  62. package/bundle/scaffold/src/lib/token.ts +186 -0
  63. package/bundle/scaffold/src/lib/utils.ts +6 -0
  64. package/bundle/scaffold/src/middleware.ts +61 -0
  65. package/bundle/scaffold/tailwind.config.ts +15 -0
  66. package/bundle/scaffold/tests/__dot__gitkeep +0 -0
  67. package/bundle/scaffold/tsconfig.json +23 -0
  68. package/bundle/scaffold/vitest.config.ts +13 -0
  69. package/bundle/toolchain/VERSION +1 -0
  70. package/bundle/toolchain/bin/check-slug.ts +59 -0
  71. package/bundle/toolchain/bin/create-deploy-bundle.ts +93 -0
  72. package/bundle/toolchain/bin/create-portal.ts +71 -0
  73. package/bundle/toolchain/bin/delete-portal.ts +48 -0
  74. package/bundle/toolchain/bin/export-file-manifest.ts +84 -0
  75. package/bundle/toolchain/bin/export-runtime-state.ts +90 -0
  76. package/bundle/toolchain/bin/generate-share-link.ts +68 -0
  77. package/bundle/toolchain/bin/list-portals.ts +53 -0
  78. package/bundle/toolchain/bin/materialize-file.ts +35 -0
  79. package/bundle/toolchain/bin/query-analytics.ts +88 -0
  80. package/bundle/toolchain/bin/rotate-credentials.ts +57 -0
  81. package/bundle/toolchain/bin/showpane-config +63 -0
  82. package/bundle/toolchain/bin/tsconfig.json +13 -0
  83. package/bundle/toolchain/skills/VERSION +1 -0
  84. package/bundle/toolchain/skills/portal-analytics/SKILL.md +263 -0
  85. package/bundle/toolchain/skills/portal-create/SKILL.md +341 -0
  86. package/bundle/toolchain/skills/portal-credentials/SKILL.md +274 -0
  87. package/bundle/toolchain/skills/portal-delete/SKILL.md +265 -0
  88. package/bundle/toolchain/skills/portal-deploy/SKILL.md +721 -0
  89. package/bundle/toolchain/skills/portal-dev/SKILL.md +301 -0
  90. package/bundle/toolchain/skills/portal-list/SKILL.md +253 -0
  91. package/bundle/toolchain/skills/portal-onboard/SKILL.md +277 -0
  92. package/bundle/toolchain/skills/portal-preview/SKILL.md +257 -0
  93. package/bundle/toolchain/skills/portal-setup/SKILL.md +309 -0
  94. package/bundle/toolchain/skills/portal-share/SKILL.md +234 -0
  95. package/bundle/toolchain/skills/portal-status/SKILL.md +268 -0
  96. package/bundle/toolchain/skills/portal-update/SKILL.md +348 -0
  97. package/bundle/toolchain/skills/portal-upgrade/SKILL.md +235 -0
  98. package/bundle/toolchain/skills/portal-verify/SKILL.md +265 -0
  99. package/bundle/toolchain/skills/shared/bin/check-portal-guard.sh +49 -0
  100. package/bundle/toolchain/skills/shared/platform-constraints.md +33 -0
  101. package/bundle/toolchain/skills/shared/preamble.md +137 -0
  102. package/bundle/toolchain/templates/consulting/consulting-client.tsx +205 -0
  103. package/bundle/toolchain/templates/onboarding/onboarding-client.tsx +237 -0
  104. package/bundle/toolchain/templates/sales-followup/sales-followup-client.tsx +283 -0
  105. package/dist/index.js +873 -166
  106. package/package.json +3 -2
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Pure HMAC-SHA256 token signing and verification.
3
+ *
4
+ * No Next.js imports. No database calls. Works in Edge, Node, and tsx scripts.
5
+ * Uses Web Crypto (crypto.subtle) which is available in Node 20+.
6
+ *
7
+ * client-auth.ts wraps these with DB lookups and Next.js request/response helpers.
8
+ * bin/ scripts import these directly for CLI token operations.
9
+ */
10
+
11
+ const encoder = new TextEncoder();
12
+ const decoder = new TextDecoder();
13
+
14
+ let cachedKey: CryptoKey | null = null;
15
+ let cachedSecret: string | null = null;
16
+
17
+ export type ClientTokenScope = "session" | "share";
18
+
19
+ export type ClientTokenPayload = {
20
+ v: 1;
21
+ orgId: string;
22
+ slug: string;
23
+ scope: ClientTokenScope;
24
+ exp: number | null;
25
+ ver: string;
26
+ jti: string;
27
+ };
28
+
29
+ export type VerifiedTokenPayload = {
30
+ orgId: string;
31
+ slug: string;
32
+ scope: ClientTokenScope;
33
+ ver: string;
34
+ };
35
+
36
+ export function getAuthSecret(): string | null {
37
+ return process.env.AUTH_SECRET ?? null;
38
+ }
39
+
40
+ export function isClientAuthConfigured(): boolean {
41
+ return Boolean(getAuthSecret());
42
+ }
43
+
44
+ async function getKey(): Promise<CryptoKey | null> {
45
+ const secret = getAuthSecret();
46
+ if (!secret) return null;
47
+
48
+ if (!cachedKey || cachedSecret !== secret) {
49
+ cachedKey = await crypto.subtle.importKey(
50
+ "raw",
51
+ encoder.encode(secret),
52
+ { name: "HMAC", hash: "SHA-256" },
53
+ false,
54
+ ["sign"]
55
+ );
56
+ cachedSecret = secret;
57
+ }
58
+ return cachedKey;
59
+ }
60
+
61
+ async function hmacHex(data: string): Promise<string | null> {
62
+ const key = await getKey();
63
+ if (!key) return null;
64
+ const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
65
+ return Array.from(new Uint8Array(sig))
66
+ .map((b) => b.toString(16).padStart(2, "0"))
67
+ .join("");
68
+ }
69
+
70
+ function encodeBase64Url(value: string): string {
71
+ const bytes = encoder.encode(value);
72
+ let binary = "";
73
+ for (const byte of bytes) {
74
+ binary += String.fromCharCode(byte);
75
+ }
76
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
77
+ }
78
+
79
+ function decodeBase64Url(value: string): string | null {
80
+ try {
81
+ const padding = "=".repeat((4 - (value.length % 4)) % 4);
82
+ const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + padding);
83
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
84
+ return decoder.decode(bytes);
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function parseTokenPayload(value: string): ClientTokenPayload | null {
91
+ try {
92
+ const parsed = JSON.parse(value) as Partial<ClientTokenPayload>;
93
+ if (
94
+ parsed.v !== 1 ||
95
+ typeof parsed.orgId !== "string" ||
96
+ typeof parsed.slug !== "string" ||
97
+ (parsed.scope !== "session" && parsed.scope !== "share") ||
98
+ (parsed.exp !== null && typeof parsed.exp !== "number") ||
99
+ typeof parsed.ver !== "string" ||
100
+ typeof parsed.jti !== "string"
101
+ ) {
102
+ return null;
103
+ }
104
+ return parsed as ClientTokenPayload;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Sign a token payload. Pure crypto, no DB.
112
+ * Caller provides the full payload including credentialVersion.
113
+ */
114
+ export async function signTokenPayload(
115
+ payload: ClientTokenPayload
116
+ ): Promise<string | null> {
117
+ const encodedPayload = encodeBase64Url(JSON.stringify(payload));
118
+ const sig = await hmacHex(encodedPayload);
119
+ if (!sig) return null;
120
+ return `${encodedPayload}.${sig}`;
121
+ }
122
+
123
+ /**
124
+ * Build and sign a token. Pure crypto, no DB.
125
+ * Caller must provide slug, scope, maxAge, and credentialVersion.
126
+ */
127
+ export async function buildAndSignToken(
128
+ orgId: string,
129
+ slug: string,
130
+ scope: ClientTokenScope,
131
+ maxAgeSeconds: number | null,
132
+ credentialVersion: string
133
+ ): Promise<string | null> {
134
+ const payload: ClientTokenPayload = {
135
+ v: 1,
136
+ orgId,
137
+ slug,
138
+ scope,
139
+ exp: maxAgeSeconds == null ? null : Math.floor(Date.now() / 1000) + maxAgeSeconds,
140
+ ver: credentialVersion,
141
+ jti: crypto.randomUUID(),
142
+ };
143
+ return signTokenPayload(payload);
144
+ }
145
+
146
+ /**
147
+ * Verify token signature and decode payload. Pure crypto, no DB.
148
+ * Does NOT check credential version against DB. Caller must do that.
149
+ * Returns the payload if signature is valid and token is not expired.
150
+ */
151
+ export async function verifyTokenSignature(
152
+ token: string,
153
+ expectedScope?: ClientTokenScope
154
+ ): Promise<VerifiedTokenPayload | null> {
155
+ const dotIndex = token.lastIndexOf(".");
156
+ if (dotIndex === -1) return null;
157
+
158
+ const encodedPayload = token.substring(0, dotIndex);
159
+ const sig = token.substring(dotIndex + 1);
160
+ const expected = await hmacHex(encodedPayload);
161
+ if (!expected) return null;
162
+
163
+ // Constant-time comparison
164
+ if (sig.length !== expected.length) return null;
165
+ let mismatch = 0;
166
+ for (let i = 0; i < sig.length; i++) {
167
+ mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
168
+ }
169
+ if (mismatch !== 0) return null;
170
+
171
+ const decodedPayload = decodeBase64Url(encodedPayload);
172
+ if (!decodedPayload) return null;
173
+
174
+ const payload = parseTokenPayload(decodedPayload);
175
+ if (!payload) return null;
176
+
177
+ if (expectedScope && payload.scope !== expectedScope) return null;
178
+ if (payload.exp != null && payload.exp <= Math.floor(Date.now() / 1000)) return null;
179
+
180
+ return { orgId: payload.orgId, slug: payload.slug, scope: payload.scope, ver: payload.ver };
181
+ }
182
+
183
+ /** Max age constants */
184
+ export const SESSION_TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
185
+ export const SHARE_TOKEN_MAX_AGE_SECONDS = null;
186
+ export const SHARE_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 10; // 10 years
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,61 @@
1
+ export const runtime = "nodejs";
2
+
3
+ import { NextRequest, NextResponse } from "next/server";
4
+ import { getAuthenticatedPortal } from "@/lib/client-auth";
5
+
6
+ export async function middleware(req: NextRequest) {
7
+ const { pathname } = req.nextUrl;
8
+
9
+ // Login page: if already authenticated, redirect to portal
10
+ if (pathname === "/client") {
11
+ try {
12
+ const portal = await getAuthenticatedPortal(req);
13
+ if (portal) {
14
+ return NextResponse.redirect(new URL(`/client/${portal.slug}`, req.url));
15
+ }
16
+ } catch (e) {
17
+ console.error("Middleware: DB error checking auth on login page", e);
18
+ // Fail open — show the login page
19
+ }
20
+ return NextResponse.next();
21
+ }
22
+
23
+ // Share link routes handle their own token verification
24
+ if (pathname.includes("/s/")) {
25
+ return NextResponse.next();
26
+ }
27
+
28
+ // API routes and static files pass through
29
+ if (pathname.startsWith("/api/") || pathname.startsWith("/_next/")) {
30
+ return NextResponse.next();
31
+ }
32
+
33
+ // Portal pages require authentication
34
+ if (pathname.startsWith("/client/")) {
35
+ try {
36
+ const portal = await getAuthenticatedPortal(req);
37
+ if (!portal) {
38
+ return NextResponse.redirect(new URL("/client", req.url));
39
+ }
40
+ // Ensure the URL slug matches the authenticated slug
41
+ const urlSlug = pathname.split("/")[2];
42
+ if (urlSlug !== portal.slug) {
43
+ return NextResponse.redirect(new URL("/client", req.url));
44
+ }
45
+ return NextResponse.next();
46
+ } catch (e) {
47
+ console.error("Middleware: DB error checking auth on portal page", e);
48
+ // Fail closed — redirect to login
49
+ return NextResponse.redirect(new URL("/client", req.url));
50
+ }
51
+ }
52
+
53
+ return NextResponse.next();
54
+ }
55
+
56
+ export const config = {
57
+ matcher: [
58
+ "/((?!_next|.*\\..*).*)",
59
+ "/(api|trpc)(.*)",
60
+ ],
61
+ };
@@ -0,0 +1,15 @@
1
+ import type { Config } from "tailwindcss";
2
+
3
+ const config: Config = {
4
+ content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
5
+ theme: {
6
+ extend: {
7
+ colors: {
8
+ primary: "var(--color-primary)",
9
+ },
10
+ },
11
+ },
12
+ plugins: [],
13
+ };
14
+
15
+ export default config;
File without changes
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
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": {
18
+ "@/*": ["./src/*"]
19
+ }
20
+ },
21
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22
+ "exclude": ["node_modules"]
23
+ }
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from "vitest/config";
2
+ import path from "path";
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ globals: true,
7
+ },
8
+ resolve: {
9
+ alias: {
10
+ "@": path.resolve(__dirname, "./src"),
11
+ },
12
+ },
13
+ });
@@ -0,0 +1 @@
1
+ 1.1.0 (requires app >= 0.2.0)
@@ -0,0 +1,59 @@
1
+ import { PrismaClient } from "@/lib/prisma-client";
2
+
3
+ const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$/;
4
+ const RESERVED = new Set([
5
+ "api", "client", "s", "admin", "static", "_next", "health", "example",
6
+ ]);
7
+
8
+ function fail(reason: string, message: string): never {
9
+ console.error(JSON.stringify({ valid: false, reason, message }));
10
+ process.exit(1);
11
+ }
12
+
13
+ function success(): never {
14
+ console.log(JSON.stringify({ valid: true }));
15
+ process.exit(0);
16
+ }
17
+
18
+ async function main() {
19
+ const args = process.argv.slice(2);
20
+
21
+ if (args.includes("--help")) {
22
+ console.log("Usage: check-slug --slug <slug> --org-id <orgId>");
23
+ console.log("Validates a portal slug for format, reserved names, and uniqueness.");
24
+ process.exit(0);
25
+ }
26
+
27
+ const getArg = (flag: string) => { const i = args.indexOf(flag); return i !== -1 ? args[i + 1] : undefined; };
28
+ const slug = getArg("--slug");
29
+ const orgId = getArg("--org-id");
30
+
31
+ if (!slug || !orgId) fail("args", "Missing --slug or --org-id");
32
+
33
+ // Format check: lowercase alphanumeric + hyphens, 2-50 chars
34
+ if (slug.length < 2 || slug.length > 50 || !SLUG_PATTERN.test(slug)) {
35
+ fail("format", "Slug must be 2-50 chars, lowercase alphanumeric and hyphens, cannot start/end with hyphen");
36
+ }
37
+
38
+ // Reserved names
39
+ if (RESERVED.has(slug)) {
40
+ fail("reserved", `"${slug}" is a reserved name`);
41
+ }
42
+
43
+ // DB uniqueness
44
+ const prisma = new PrismaClient();
45
+ try {
46
+ const existing = await prisma.clientPortal.findUnique({
47
+ where: { organizationId_slug: { organizationId: orgId, slug } },
48
+ });
49
+ if (existing) fail("taken", `Slug "${slug}" is already in use`);
50
+ success();
51
+ } finally {
52
+ await prisma.$disconnect();
53
+ }
54
+ }
55
+
56
+ main().catch((e) => {
57
+ console.error(JSON.stringify({ valid: false, reason: "error", message: String(e) }));
58
+ process.exit(1);
59
+ });
@@ -0,0 +1,93 @@
1
+ import AdmZip from "adm-zip";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ function fail(message: string): never {
6
+ console.error(JSON.stringify({ ok: false, error: message }));
7
+ process.exit(1);
8
+ }
9
+
10
+ function walkFiles(dir: string, root: string, out: Set<string>) {
11
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
12
+ const fullPath = path.join(dir, entry.name);
13
+ if (entry.isDirectory()) {
14
+ walkFiles(fullPath, root, out);
15
+ continue;
16
+ }
17
+ out.add(path.relative(root, fullPath));
18
+ }
19
+ }
20
+
21
+ function collectTracedFiles(appPath: string): Set<string> {
22
+ const files = new Set<string>();
23
+ const outputRoot = path.join(appPath, ".vercel", "output");
24
+ const functionsRoot = path.join(outputRoot, "functions");
25
+
26
+ walkFiles(outputRoot, appPath, files);
27
+
28
+ const queue = [functionsRoot];
29
+ while (queue.length > 0) {
30
+ const current = queue.pop();
31
+ if (!current) continue;
32
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
33
+ const fullPath = path.join(current, entry.name);
34
+ if (entry.isDirectory()) {
35
+ queue.push(fullPath);
36
+ continue;
37
+ }
38
+ if (entry.name !== ".vc-config.json") continue;
39
+
40
+ const config = JSON.parse(readFileSync(fullPath, "utf8")) as {
41
+ filePathMap?: Record<string, string>;
42
+ };
43
+ for (const relativePath of Object.values(config.filePathMap ?? {})) {
44
+ files.add(relativePath);
45
+ }
46
+ }
47
+ }
48
+
49
+ return files;
50
+ }
51
+
52
+ async function main() {
53
+ const args = process.argv.slice(2);
54
+ const outputIndex = args.indexOf("--output");
55
+ const outputPath = outputIndex !== -1 ? args[outputIndex + 1] : undefined;
56
+
57
+ if (!outputPath) {
58
+ fail("Missing --output");
59
+ }
60
+
61
+ const appPath = process.cwd();
62
+ const outputRoot = path.join(appPath, ".vercel", "output");
63
+ if (!statSync(outputRoot, { throwIfNoEntry: false })?.isDirectory()) {
64
+ fail("Missing .vercel/output. Run a prebuilt Vercel build first.");
65
+ }
66
+
67
+ const zip = new AdmZip();
68
+ const tracedFiles = collectTracedFiles(appPath);
69
+
70
+ for (const relativePath of tracedFiles) {
71
+ const normalized = relativePath.replace(/\\/g, "/");
72
+
73
+ if (normalized === ".env" || normalized.startsWith(".env.")) {
74
+ zip.addFile(normalized, Buffer.from("NODE_ENV=production\n"));
75
+ continue;
76
+ }
77
+
78
+ const fullPath = path.join(appPath, relativePath);
79
+ const stat = statSync(fullPath, { throwIfNoEntry: false });
80
+ if (!stat?.isFile()) {
81
+ continue;
82
+ }
83
+
84
+ zip.addLocalFile(fullPath, path.dirname(normalized), path.basename(normalized));
85
+ }
86
+
87
+ zip.writeZip(outputPath);
88
+ console.log(JSON.stringify({ ok: true, outputPath, fileCount: tracedFiles.size }));
89
+ }
90
+
91
+ main().catch((error) => {
92
+ fail(String(error));
93
+ });
@@ -0,0 +1,71 @@
1
+ import { PrismaClient } from "@/lib/prisma-client";
2
+ import bcrypt from "bcryptjs";
3
+ import crypto from "node:crypto";
4
+
5
+ const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$/;
6
+ const RESERVED = new Set([
7
+ "api", "client", "s", "admin", "static", "_next", "health", "example",
8
+ ]);
9
+
10
+ function fail(message: string): never {
11
+ console.error(JSON.stringify({ ok: false, error: message }));
12
+ process.exit(1);
13
+ }
14
+
15
+ async function main() {
16
+ const args = process.argv.slice(2);
17
+
18
+ if (args.includes("--help")) {
19
+ console.log("Usage: create-portal --slug <slug> --company <name> --org-id <orgId>");
20
+ console.log("Creates a new client portal with auto-generated credentials.");
21
+ process.exit(0);
22
+ }
23
+
24
+ const getArg = (flag: string) => { const i = args.indexOf(flag); return i !== -1 ? args[i + 1] : undefined; };
25
+ const slug = getArg("--slug");
26
+ const company = getArg("--company");
27
+ const orgId = getArg("--org-id");
28
+
29
+ if (!slug || !company || !orgId) fail("Missing --slug, --company, or --org-id");
30
+
31
+ // Validate slug
32
+ if (slug.length < 2 || slug.length > 50 || !SLUG_PATTERN.test(slug)) {
33
+ fail("Invalid slug format: 2-50 chars, lowercase alphanumeric and hyphens");
34
+ }
35
+ if (RESERVED.has(slug)) fail(`"${slug}" is a reserved name`);
36
+
37
+ const username = slug;
38
+ const password = crypto.randomBytes(16).toString("base64url");
39
+ const passwordHash = await bcrypt.hash(password, 10);
40
+
41
+ const prisma = new PrismaClient();
42
+ try {
43
+ // Check uniqueness
44
+ const existing = await prisma.clientPortal.findUnique({
45
+ where: { organizationId_slug: { organizationId: orgId, slug } },
46
+ });
47
+ if (existing) fail(`Slug "${slug}" already exists`);
48
+
49
+ const portal = await prisma.clientPortal.create({
50
+ data: {
51
+ organizationId: orgId,
52
+ slug,
53
+ companyName: company,
54
+ username,
55
+ passwordHash,
56
+ },
57
+ });
58
+
59
+ console.log(JSON.stringify({
60
+ ok: true,
61
+ portal: { id: portal.id, slug: portal.slug, username, password },
62
+ }));
63
+ } finally {
64
+ await prisma.$disconnect();
65
+ }
66
+ }
67
+
68
+ main().catch((e) => {
69
+ console.error(JSON.stringify({ ok: false, error: String(e) }));
70
+ process.exit(1);
71
+ });
@@ -0,0 +1,48 @@
1
+ import { PrismaClient } from "@/lib/prisma-client";
2
+
3
+ function fail(message: string): never {
4
+ console.error(JSON.stringify({ ok: false, error: message }));
5
+ process.exit(1);
6
+ }
7
+
8
+ async function main() {
9
+ const args = process.argv.slice(2);
10
+
11
+ if (args.includes("--help")) {
12
+ console.log("Usage: delete-portal --slug <slug> --org-id <orgId>");
13
+ console.log("Soft-deletes a portal by setting isActive=false. Idempotent.");
14
+ process.exit(0);
15
+ }
16
+
17
+ const getArg = (flag: string) => { const i = args.indexOf(flag); return i !== -1 ? args[i + 1] : undefined; };
18
+ const slug = getArg("--slug");
19
+ const orgId = getArg("--org-id");
20
+
21
+ if (!slug || !orgId) fail("Missing --slug or --org-id");
22
+
23
+ const prisma = new PrismaClient();
24
+ try {
25
+ const portal = await prisma.clientPortal.findUnique({
26
+ where: { organizationId_slug: { organizationId: orgId, slug } },
27
+ });
28
+ if (!portal) fail(`Portal "${slug}" not found`);
29
+
30
+ const wasActive = portal.isActive;
31
+
32
+ if (wasActive) {
33
+ await prisma.clientPortal.update({
34
+ where: { id: portal.id },
35
+ data: { isActive: false },
36
+ });
37
+ }
38
+
39
+ console.log(JSON.stringify({ ok: true, slug, wasActive }));
40
+ } finally {
41
+ await prisma.$disconnect();
42
+ }
43
+ }
44
+
45
+ main().catch((e) => {
46
+ console.error(JSON.stringify({ ok: false, error: String(e) }));
47
+ process.exit(1);
48
+ });
@@ -0,0 +1,84 @@
1
+ import { PrismaClient } from "@/lib/prisma-client";
2
+ import { createHash } from "node:crypto";
3
+ import { readFile_ } from "@/lib/storage";
4
+
5
+ function fail(message: string): never {
6
+ console.error(JSON.stringify({ error: message }));
7
+ process.exit(1);
8
+ }
9
+
10
+ function parseArgs(argv: string[]) {
11
+ const getArg = (flag: string) => {
12
+ const index = argv.indexOf(flag);
13
+ return index !== -1 ? argv[index + 1] : undefined;
14
+ };
15
+
16
+ return {
17
+ orgId: getArg("--org-id"),
18
+ orgSlug: getArg("--org-slug"),
19
+ };
20
+ }
21
+
22
+ async function main() {
23
+ const args = process.argv.slice(2);
24
+
25
+ if (args.includes("--help")) {
26
+ console.log("Usage: export-file-manifest [--org-id <orgId>] [--org-slug <slug>]");
27
+ console.log("Exports uploaded file metadata and checksums for cloud file sync.");
28
+ process.exit(0);
29
+ }
30
+
31
+ const { orgId, orgSlug } = parseArgs(args);
32
+ const prisma = new PrismaClient();
33
+
34
+ try {
35
+ const organization = orgId
36
+ ? await prisma.organization.findUnique({ where: { id: orgId }, select: { id: true } })
37
+ : orgSlug
38
+ ? await prisma.organization.findUnique({ where: { slug: orgSlug }, select: { id: true } })
39
+ : await prisma.organization.findFirst({ orderBy: { createdAt: "asc" }, select: { id: true } });
40
+
41
+ if (!organization) {
42
+ fail("No organization found");
43
+ }
44
+
45
+ const files = await prisma.portalFile.findMany({
46
+ where: { portal: { organizationId: organization.id } },
47
+ include: {
48
+ portal: {
49
+ select: {
50
+ slug: true,
51
+ },
52
+ },
53
+ },
54
+ orderBy: { uploadedAt: "asc" },
55
+ });
56
+
57
+ const manifest = [];
58
+ for (const file of files) {
59
+ const data = await readFile_(file.storagePath);
60
+ if (!data) {
61
+ fail(`File missing from storage: ${file.storagePath}`);
62
+ }
63
+
64
+ manifest.push({
65
+ portalSlug: file.portal.slug,
66
+ storagePath: file.storagePath,
67
+ filename: file.filename,
68
+ mimeType: file.mimeType,
69
+ size: file.size,
70
+ uploadedBy: file.uploadedBy,
71
+ uploadedAt: file.uploadedAt.toISOString(),
72
+ checksum: file.checksum ?? createHash("sha256").update(data).digest("hex"),
73
+ });
74
+ }
75
+
76
+ console.log(JSON.stringify({ files: manifest }));
77
+ } finally {
78
+ await prisma.$disconnect();
79
+ }
80
+ }
81
+
82
+ main().catch((error) => {
83
+ fail(String(error));
84
+ });