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.
- package/README.md +14 -1
- package/bundle/meta/scaffold-manifest.json +73 -0
- package/bundle/scaffold/VERSION +1 -0
- package/bundle/scaffold/__dot__env.example +24 -0
- package/bundle/scaffold/__dot__gitignore +41 -0
- package/bundle/scaffold/docker/Caddyfile +3 -0
- package/bundle/scaffold/docker/Dockerfile +30 -0
- package/bundle/scaffold/docker-compose.yml +53 -0
- package/bundle/scaffold/next.config.ts +20 -0
- package/bundle/scaffold/package-lock.json +5843 -0
- package/bundle/scaffold/package.json +42 -0
- package/bundle/scaffold/postcss.config.js +6 -0
- package/bundle/scaffold/prisma/migrations/20260408000000_init/migration.sql +143 -0
- package/bundle/scaffold/prisma/migrations/20260408010000_add_visitor_tracking/migration.sql +6 -0
- package/bundle/scaffold/prisma/migrations/20260409040000_add_portal_file_checksum/migration.sql +2 -0
- package/bundle/scaffold/prisma/migrations/migration_lock.toml +3 -0
- package/bundle/scaffold/prisma/schema.local.prisma +131 -0
- package/bundle/scaffold/prisma/schema.prisma +128 -0
- package/bundle/scaffold/prisma/seed.ts +49 -0
- package/bundle/scaffold/public/example-avatar.svg +4 -0
- package/bundle/scaffold/public/example-logo.svg +4 -0
- package/bundle/scaffold/public/robots.txt +2 -0
- package/bundle/scaffold/scripts/backup.sh +19 -0
- package/bundle/scaffold/scripts/e2e-verify.sh +487 -0
- package/bundle/scaffold/scripts/prisma-db-push.mjs +7 -0
- package/bundle/scaffold/scripts/prisma-generate.mjs +3 -0
- package/bundle/scaffold/scripts/prisma-schema.mjs +74 -0
- package/bundle/scaffold/scripts/restore.sh +31 -0
- package/bundle/scaffold/src/__tests__/client-portals.test.ts +80 -0
- package/bundle/scaffold/src/__tests__/portal-contracts.test.ts +32 -0
- package/bundle/scaffold/src/app/(portal)/client/[slug]/page.tsx +79 -0
- package/bundle/scaffold/src/app/(portal)/client/[slug]/s/[token]/route.ts +22 -0
- package/bundle/scaffold/src/app/(portal)/client/example/example-client.tsx +372 -0
- package/bundle/scaffold/src/app/(portal)/client/example/page.tsx +5 -0
- package/bundle/scaffold/src/app/(portal)/client/layout.tsx +7 -0
- package/bundle/scaffold/src/app/(portal)/client/page.tsx +18 -0
- package/bundle/scaffold/src/app/api/client-auth/route.ts +82 -0
- package/bundle/scaffold/src/app/api/client-auth/share/route.ts +30 -0
- package/bundle/scaffold/src/app/api/client-events/route.ts +87 -0
- package/bundle/scaffold/src/app/api/client-files/[...path]/route.ts +80 -0
- package/bundle/scaffold/src/app/api/client-files/client-upload/route.ts +118 -0
- package/bundle/scaffold/src/app/api/client-files/route.ts +37 -0
- package/bundle/scaffold/src/app/api/client-files/upload/route.ts +131 -0
- package/bundle/scaffold/src/app/api/health/route.ts +19 -0
- package/bundle/scaffold/src/app/globals.css +7 -0
- package/bundle/scaffold/src/app/layout.tsx +25 -0
- package/bundle/scaffold/src/app/page.tsx +171 -0
- package/bundle/scaffold/src/components/portal-login.tsx +169 -0
- package/bundle/scaffold/src/components/portal-shell.tsx +373 -0
- package/bundle/scaffold/src/lib/abuse-controls.ts +43 -0
- package/bundle/scaffold/src/lib/branding.ts +50 -0
- package/bundle/scaffold/src/lib/client-auth.ts +98 -0
- package/bundle/scaffold/src/lib/client-portals.ts +134 -0
- package/bundle/scaffold/src/lib/control-plane.ts +100 -0
- package/bundle/scaffold/src/lib/db.ts +7 -0
- package/bundle/scaffold/src/lib/files.ts +124 -0
- package/bundle/scaffold/src/lib/load-app-env.ts +42 -0
- package/bundle/scaffold/src/lib/portal-contracts.ts +69 -0
- package/bundle/scaffold/src/lib/prisma-client.ts +5 -0
- package/bundle/scaffold/src/lib/runtime-state.ts +69 -0
- package/bundle/scaffold/src/lib/storage.ts +204 -0
- package/bundle/scaffold/src/lib/token.ts +186 -0
- package/bundle/scaffold/src/lib/utils.ts +6 -0
- package/bundle/scaffold/src/middleware.ts +61 -0
- package/bundle/scaffold/tailwind.config.ts +15 -0
- package/bundle/scaffold/tests/__dot__gitkeep +0 -0
- package/bundle/scaffold/tsconfig.json +23 -0
- package/bundle/scaffold/vitest.config.ts +13 -0
- package/bundle/toolchain/VERSION +1 -0
- package/bundle/toolchain/bin/check-slug.ts +59 -0
- package/bundle/toolchain/bin/create-deploy-bundle.ts +93 -0
- package/bundle/toolchain/bin/create-portal.ts +71 -0
- package/bundle/toolchain/bin/delete-portal.ts +48 -0
- package/bundle/toolchain/bin/export-file-manifest.ts +84 -0
- package/bundle/toolchain/bin/export-runtime-state.ts +90 -0
- package/bundle/toolchain/bin/generate-share-link.ts +68 -0
- package/bundle/toolchain/bin/list-portals.ts +53 -0
- package/bundle/toolchain/bin/materialize-file.ts +35 -0
- package/bundle/toolchain/bin/query-analytics.ts +88 -0
- package/bundle/toolchain/bin/rotate-credentials.ts +57 -0
- package/bundle/toolchain/bin/showpane-config +63 -0
- package/bundle/toolchain/bin/tsconfig.json +13 -0
- package/bundle/toolchain/skills/VERSION +1 -0
- package/bundle/toolchain/skills/portal-analytics/SKILL.md +263 -0
- package/bundle/toolchain/skills/portal-create/SKILL.md +341 -0
- package/bundle/toolchain/skills/portal-credentials/SKILL.md +274 -0
- package/bundle/toolchain/skills/portal-delete/SKILL.md +265 -0
- package/bundle/toolchain/skills/portal-deploy/SKILL.md +721 -0
- package/bundle/toolchain/skills/portal-dev/SKILL.md +301 -0
- package/bundle/toolchain/skills/portal-list/SKILL.md +253 -0
- package/bundle/toolchain/skills/portal-onboard/SKILL.md +277 -0
- package/bundle/toolchain/skills/portal-preview/SKILL.md +257 -0
- package/bundle/toolchain/skills/portal-setup/SKILL.md +309 -0
- package/bundle/toolchain/skills/portal-share/SKILL.md +234 -0
- package/bundle/toolchain/skills/portal-status/SKILL.md +268 -0
- package/bundle/toolchain/skills/portal-update/SKILL.md +348 -0
- package/bundle/toolchain/skills/portal-upgrade/SKILL.md +235 -0
- package/bundle/toolchain/skills/portal-verify/SKILL.md +265 -0
- package/bundle/toolchain/skills/shared/bin/check-portal-guard.sh +49 -0
- package/bundle/toolchain/skills/shared/platform-constraints.md +33 -0
- package/bundle/toolchain/skills/shared/preamble.md +137 -0
- package/bundle/toolchain/templates/consulting/consulting-client.tsx +205 -0
- package/bundle/toolchain/templates/onboarding/onboarding-client.tsx +237 -0
- package/bundle/toolchain/templates/sales-followup/sales-followup-client.tsx +283 -0
- package/dist/index.js +873 -166
- 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,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 @@
|
|
|
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
|
+
});
|