@naviyra/cloud-panel 0.0.0-stage → 0.0.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 (56) hide show
  1. package/.env.example +40 -0
  2. package/README.md +145 -2
  3. package/apps/agent/package.json +13 -0
  4. package/apps/agent/src/cloudflare.mjs +733 -0
  5. package/apps/agent/src/native-worker.mjs +52 -0
  6. package/apps/agent/src/native.mjs +457 -0
  7. package/apps/agent/src/runtime.mjs +760 -0
  8. package/apps/agent/src/server.mjs +195 -0
  9. package/apps/agent/src/static-server.mjs +53 -0
  10. package/apps/panel/AGENTS.md +9 -0
  11. package/apps/panel/CLAUDE.md +1 -0
  12. package/apps/panel/app/api/[...path]/route.js +445 -0
  13. package/apps/panel/app/globals.css +2057 -0
  14. package/apps/panel/app/icon.png +0 -0
  15. package/apps/panel/app/layout.jsx +18 -0
  16. package/apps/panel/app/login/page.jsx +108 -0
  17. package/apps/panel/app/page.jsx +33 -0
  18. package/apps/panel/app/ui/cloudflare.jsx +926 -0
  19. package/apps/panel/app/ui/dashboard.jsx +1487 -0
  20. package/apps/panel/app/ui/databases.jsx +293 -0
  21. package/apps/panel/app/ui/skeleton.jsx +196 -0
  22. package/apps/panel/app/ui/theme.jsx +35 -0
  23. package/apps/panel/lib/agent.mjs +83 -0
  24. package/apps/panel/lib/auth.mjs +48 -0
  25. package/apps/panel/lib/database-runtime.mjs +243 -0
  26. package/apps/panel/lib/databases.mjs +292 -0
  27. package/apps/panel/lib/db.mjs +6 -0
  28. package/apps/panel/next.config.mjs +8 -0
  29. package/apps/panel/package.json +22 -0
  30. package/apps/panel/public/naviyra-logo.png +0 -0
  31. package/bin/naviyra.mjs +215 -0
  32. package/db/001_init.sql +28 -0
  33. package/db/002_native_runtimes.sql +3 -0
  34. package/db/003_databases.sql +8 -0
  35. package/db/004_database_engines.sql +4 -0
  36. package/deploy/naviyra-agent.service +17 -0
  37. package/docker-compose.yml +18 -0
  38. package/docs/managed-runtime.md +52 -0
  39. package/docs/native-hosts.md +48 -0
  40. package/package.json +61 -4
  41. package/packages/runtime/artifacts.json +32 -0
  42. package/packages/runtime/index.cjs +526 -0
  43. package/packages/runtime/index.mjs +10 -0
  44. package/packages/runtime/package.json +6 -0
  45. package/packages/shared/index.mjs +85 -0
  46. package/packages/shared/package.json +9 -0
  47. package/scripts/migrate.mjs +18 -0
  48. package/scripts/panel-control.mjs +195 -0
  49. package/scripts/postgres-local.mjs +97 -0
  50. package/scripts/runtime-download-manifest.mjs +30 -0
  51. package/scripts/runtime.mjs +20 -0
  52. package/scripts/seed.mjs +35 -0
  53. package/scripts/service-supervisor.mjs +164 -0
  54. package/scripts/setup-local.mjs +215 -0
  55. package/scripts/start-naviyra.mjs +303 -0
  56. package/scripts/windows-boot.mjs +259 -0
@@ -0,0 +1,293 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import { Copy, Database, Plus, Trash2 } from "lucide-react";
4
+ import { SkeletonTable } from "./skeleton";
5
+
6
+ async function api(url, options = {}) {
7
+ const response = await fetch(`/api/${url}`, {
8
+ ...options,
9
+ headers: { "Content-Type": "application/json", ...options.headers },
10
+ });
11
+ const body = await response.json();
12
+ if (response.status === 401) {
13
+ window.location.href = "/login";
14
+ throw Error("Session expired");
15
+ }
16
+ if (!response.ok) throw Error(body.error || "Request failed");
17
+ return body;
18
+ }
19
+
20
+ export function DatabaseScreen({ canWrite }) {
21
+ const [data, setData] = useState(null);
22
+ const [loading, setLoading] = useState(true);
23
+ const [busy, setBusy] = useState(false);
24
+ const [error, setError] = useState("");
25
+ const [secret, setSecret] = useState(null);
26
+ const load = useCallback(async () => {
27
+ try {
28
+ setData(await api("databases"));
29
+ setError("");
30
+ } catch (e) {
31
+ setError(e.message);
32
+ } finally {
33
+ setLoading(false);
34
+ }
35
+ }, []);
36
+ useEffect(() => {
37
+ load();
38
+ }, [load]);
39
+ const create = async (event) => {
40
+ event.preventDefault();
41
+ const form = event.currentTarget;
42
+ const name = new FormData(form).get("name");
43
+ const engine = new FormData(form).get("engine");
44
+ setBusy(true);
45
+ setError("");
46
+ try {
47
+ const created = await api("databases", {
48
+ method: "POST",
49
+ body: JSON.stringify({
50
+ name,
51
+ ...(engine === "native" ? {} : { engine }),
52
+ }),
53
+ });
54
+ form.reset();
55
+ setSecret(created);
56
+ await load();
57
+ } catch (e) {
58
+ setError(e.message);
59
+ } finally {
60
+ setBusy(false);
61
+ }
62
+ };
63
+ const resetPassword = async (item) => {
64
+ if (
65
+ !window.confirm(
66
+ `Replace the password for ${item.database}? The current password will stop working.`,
67
+ )
68
+ )
69
+ return;
70
+ setBusy(true);
71
+ setError("");
72
+ try {
73
+ setSecret(await api(`databases/${item.id}/password`, { method: "POST" }));
74
+ } catch (e) {
75
+ setError(e.message);
76
+ } finally {
77
+ setBusy(false);
78
+ }
79
+ };
80
+ const remove = async (item) => {
81
+ if (
82
+ !window.confirm(
83
+ `Delete database ${item.database}? Applications using it will lose this data.`,
84
+ )
85
+ )
86
+ return;
87
+ setBusy(true);
88
+ setError("");
89
+ try {
90
+ await api(`databases/${item.id}`, { method: "DELETE" });
91
+ if (secret?.id === item.id) setSecret(null);
92
+ await load();
93
+ } catch (e) {
94
+ setError(e.message);
95
+ } finally {
96
+ setBusy(false);
97
+ }
98
+ };
99
+ const copy = async (value) => {
100
+ try {
101
+ await navigator.clipboard.writeText(value);
102
+ } catch {
103
+ setError("Could not copy to the clipboard.");
104
+ }
105
+ };
106
+ const rows = data?.databases || [];
107
+ return (
108
+ <>
109
+ <div className="page-heading">
110
+ <div>
111
+ <h1>
112
+ Databases<span className="title-dot">.</span>
113
+ </h1>
114
+ <p>
115
+ Choose a database for applications on this server. Missing engines
116
+ download automatically.
117
+ </p>
118
+ </div>
119
+ </div>
120
+ <div className="stack database-screen">
121
+ {error && <div className="error">{error}</div>}
122
+ {secret && (
123
+ <section className="credential">
124
+ <strong>Password for {secret.database}</strong>
125
+ <p>Save this password. It is shown only this time.</p>
126
+ <div className="credential-row">
127
+ <input
128
+ readOnly
129
+ value={secret.password}
130
+ aria-label="Database password"
131
+ />
132
+ <button
133
+ className="secondary"
134
+ type="button"
135
+ onClick={() => copy(secret.password)}
136
+ >
137
+ <Copy size={14} />
138
+ Copy
139
+ </button>
140
+ </div>
141
+ <button
142
+ className="text-button"
143
+ type="button"
144
+ onClick={() =>
145
+ copy(
146
+ `${secret.engine === "postgresql" ? "postgresql" : "mysql"}://${secret.user}:${encodeURIComponent(secret.password)}@${secret.host}:${secret.port}/${secret.database}`,
147
+ )
148
+ }
149
+ >
150
+ Copy connection string
151
+ </button>
152
+ </section>
153
+ )}
154
+ <section className="panel">
155
+ <form className="database-create" onSubmit={create}>
156
+ <label>
157
+ Database engine
158
+ <small>
159
+ Naviyra installs and manages the runtime automatically.
160
+ </small>
161
+ <select
162
+ name="engine"
163
+ aria-label="Database engine"
164
+ disabled={!canWrite || busy}
165
+ >
166
+ {(
167
+ data?.engines || [
168
+ { value: "postgresql", label: "PostgreSQL" },
169
+ { value: "mysql", label: "MySQL" },
170
+ { value: "mariadb", label: "MariaDB" },
171
+ ]
172
+ ).map((engine) => (
173
+ <option key={engine.value} value={engine.value}>
174
+ {engine.label}
175
+ </option>
176
+ ))}
177
+ <option value="native">
178
+ PostgreSQL (existing native server)
179
+ </option>
180
+ </select>
181
+ </label>
182
+ <label>
183
+ Database name
184
+ <small>Lowercase letters, numbers, and underscores.</small>
185
+ <input
186
+ name="name"
187
+ placeholder="crm"
188
+ aria-label="Database name"
189
+ autoComplete="off"
190
+ required
191
+ disabled={!canWrite || busy}
192
+ />
193
+ </label>
194
+ <button className="primary" disabled={!canWrite || busy}>
195
+ <Plus size={16} />
196
+ {busy ? "Working…" : "Create database"}
197
+ </button>
198
+ </form>
199
+ {busy && (
200
+ <p role="status">
201
+ Preparing the runtime and database. First setup can take several
202
+ minutes. Keep this page open.
203
+ </p>
204
+ )}
205
+ </section>
206
+ <section className="panel">
207
+ {!data && loading ? (
208
+ <SkeletonTable
209
+ label="Loading databases"
210
+ columns={[
211
+ "Name",
212
+ "Engine",
213
+ "User",
214
+ "Host",
215
+ "Port",
216
+ "Created",
217
+ "",
218
+ ]}
219
+ />
220
+ ) : rows.length ? (
221
+ <div className="table-wrap">
222
+ <table>
223
+ <thead>
224
+ <tr>
225
+ <th>Name</th>
226
+ <th>Engine</th>
227
+ <th>User</th>
228
+ <th>Host</th>
229
+ <th>Port</th>
230
+ <th>Created</th>
231
+ <th></th>
232
+ </tr>
233
+ </thead>
234
+ <tbody>
235
+ {rows.map((item) => (
236
+ <tr key={item.id}>
237
+ <td className="mono">{item.database}</td>
238
+ <td>
239
+ {{
240
+ postgresql: "PostgreSQL",
241
+ mysql: "MySQL",
242
+ mariadb: "MariaDB",
243
+ }[item.engine] || item.engine}
244
+ </td>
245
+ <td className="mono">{item.user}</td>
246
+ <td className="mono">{item.host}</td>
247
+ <td>{item.port}</td>
248
+ <td>{new Date(item.created_at).toLocaleDateString()}</td>
249
+ <td>
250
+ {canWrite && (
251
+ <span className="row-actions">
252
+ <button
253
+ className="text-button"
254
+ type="button"
255
+ disabled={busy}
256
+ onClick={() => resetPassword(item)}
257
+ >
258
+ New password
259
+ </button>
260
+ <button
261
+ className="text-button danger-text"
262
+ type="button"
263
+ disabled={busy}
264
+ onClick={() => remove(item)}
265
+ >
266
+ <Trash2 size={14} />
267
+ Delete
268
+ </button>
269
+ </span>
270
+ )}
271
+ </td>
272
+ </tr>
273
+ ))}
274
+ </tbody>
275
+ </table>
276
+ </div>
277
+ ) : (
278
+ <div className="empty compact-empty">
279
+ <div className="empty-icon">
280
+ <Database size={28} />
281
+ </div>
282
+ <h3>No databases yet</h3>
283
+ <p>
284
+ Create one and use its host, user, and password in your
285
+ application.
286
+ </p>
287
+ </div>
288
+ )}
289
+ </section>
290
+ </div>
291
+ </>
292
+ );
293
+ }
@@ -0,0 +1,196 @@
1
+ export function SkeletonTable({ label, columns, rows = 6, tableClass = "" }) {
2
+ return (
3
+ <div className="table-wrap skeleton-table-wrap">
4
+ <table className={tableClass} aria-busy="true" aria-label={label}>
5
+ <thead>
6
+ <tr>
7
+ {columns.map((column) => (
8
+ <th key={column || "actions"}>{column}</th>
9
+ ))}
10
+ </tr>
11
+ </thead>
12
+ <tbody>
13
+ {Array.from({ length: rows }, (_, row) => (
14
+ <tr key={row} className="skeleton-row">
15
+ {columns.map((column, index) => (
16
+ <td key={`${column}-${index}`}>
17
+ {column ? <span className="skeleton" /> : null}
18
+ </td>
19
+ ))}
20
+ </tr>
21
+ ))}
22
+ </tbody>
23
+ </table>
24
+ </div>
25
+ );
26
+ }
27
+
28
+ export function SkeletonStats({ count = 4, label = "Loading" }) {
29
+ return (
30
+ <div className="stats" aria-busy="true" aria-label={label}>
31
+ {Array.from({ length: count }, (_, index) => (
32
+ <div key={index} className="stat skeleton-card">
33
+ <span className="skeleton" />
34
+ <span className="skeleton" />
35
+ <span className="skeleton" />
36
+ </div>
37
+ ))}
38
+ </div>
39
+ );
40
+ }
41
+
42
+ export function SkeletonRows({ rows = 5, label = "Loading" }) {
43
+ return (
44
+ <div aria-busy="true" aria-label={label}>
45
+ {Array.from({ length: rows }, (_, index) => (
46
+ <div key={index} className="skeleton-line">
47
+ <span className="skeleton sq" />
48
+ <span className="skeleton grow" />
49
+ <span className="skeleton mid" />
50
+ </div>
51
+ ))}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ export function ProjectSkeleton() {
57
+ return (
58
+ <div aria-busy="true" aria-label="Loading project">
59
+ <div className="page-heading">
60
+ <span className="skeleton title" />
61
+ <span className="skeleton button" />
62
+ </div>
63
+ <SkeletonStats label="Loading project" />
64
+ <section className="panel">
65
+ <SkeletonRows rows={4} label="Loading project" />
66
+ </section>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ export function EmailSkeleton() {
72
+ return (
73
+ <div className="stack" aria-busy="true" aria-label="Loading email routing">
74
+ <section className="panel skeleton-status">
75
+ <span className="skeleton" />
76
+ <span className="skeleton" />
77
+ </section>
78
+ <section className="panel">
79
+ <div className="section-heading">
80
+ <h2>Destination addresses</h2>
81
+ </div>
82
+ <SkeletonTable
83
+ label="Loading destination addresses"
84
+ columns={["Email", "Verified", ""]}
85
+ rows={3}
86
+ />
87
+ </section>
88
+ <section className="panel">
89
+ <div className="section-heading">
90
+ <h2>Routing rules</h2>
91
+ </div>
92
+ <SkeletonTable
93
+ label="Loading routing rules"
94
+ columns={["Name", "Match", "Action", "Enabled", ""]}
95
+ rows={3}
96
+ />
97
+ </section>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ export function PageSkeleton({ view }) {
103
+ if (view === "project") return <ProjectSkeleton />;
104
+ if (view === "infrastructure") {
105
+ return (
106
+ <>
107
+ <div className="page-heading">
108
+ <h1>
109
+ Infrastructure<span className="title-dot">.</span>
110
+ </h1>
111
+ </div>
112
+ <SkeletonStats label="Loading infrastructure" />
113
+ <section className="panel">
114
+ <SkeletonRows rows={4} label="Loading infrastructure" />
115
+ </section>
116
+ </>
117
+ );
118
+ }
119
+ if (view === "activity") {
120
+ return (
121
+ <>
122
+ <div className="page-heading">
123
+ <h1>
124
+ Project activity<span className="title-dot">.</span>
125
+ </h1>
126
+ </div>
127
+ <section className="panel">
128
+ <SkeletonRows label="Loading activity" />
129
+ </section>
130
+ </>
131
+ );
132
+ }
133
+ if (view === "dns") {
134
+ return (
135
+ <>
136
+ <div className="page-heading">
137
+ <h1>
138
+ DNS records<span className="title-dot">.</span>
139
+ </h1>
140
+ </div>
141
+ <section className="panel">
142
+ <SkeletonTable
143
+ label="Loading DNS records"
144
+ tableClass="dns-table"
145
+ columns={["Type", "Name", "Content", "Proxy", "TTL", ""]}
146
+ />
147
+ </section>
148
+ </>
149
+ );
150
+ }
151
+ if (view === "databases") {
152
+ return (
153
+ <>
154
+ <div className="page-heading">
155
+ <h1>
156
+ Databases<span className="title-dot">.</span>
157
+ </h1>
158
+ </div>
159
+ <section className="panel">
160
+ <SkeletonTable
161
+ label="Loading databases"
162
+ columns={["Name", "User", "Host", "Port", "Created", ""]}
163
+ />
164
+ </section>
165
+ </>
166
+ );
167
+ }
168
+ if (view === "email") {
169
+ return (
170
+ <>
171
+ <div className="page-heading">
172
+ <h1>
173
+ Email routing<span className="title-dot">.</span>
174
+ </h1>
175
+ </div>
176
+ <EmailSkeleton />
177
+ </>
178
+ );
179
+ }
180
+ return (
181
+ <>
182
+ <div className="page-heading">
183
+ <h1>
184
+ Projects<span className="title-dot">.</span>
185
+ </h1>
186
+ </div>
187
+ <SkeletonStats label="Loading projects" />
188
+ <section className="panel">
189
+ <SkeletonTable
190
+ label="Loading projects"
191
+ columns={["Project", "Status", "Domain", "Port", "Updated", ""]}
192
+ />
193
+ </section>
194
+ </>
195
+ );
196
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { useEffect } from "react";
3
+ import { Moon, Sun } from "lucide-react";
4
+
5
+ export function ThemeSync() {
6
+ useEffect(() => {
7
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
8
+ const apply = () => {
9
+ if (localStorage.getItem("naviyra-theme")) return;
10
+ document.documentElement.dataset.theme = media.matches ? "dark" : "light";
11
+ };
12
+ media.addEventListener("change", apply);
13
+ return () => media.removeEventListener("change", apply);
14
+ }, []);
15
+ return null;
16
+ }
17
+
18
+ export function ThemeToggle({ className = "" }) {
19
+ return (
20
+ <button
21
+ type="button"
22
+ className={`theme-toggle ${className}`.trim()}
23
+ aria-label="Toggle color theme"
24
+ onClick={() => {
25
+ const next =
26
+ document.documentElement.dataset.theme === "dark" ? "light" : "dark";
27
+ document.documentElement.dataset.theme = next;
28
+ localStorage.setItem("naviyra-theme", next);
29
+ }}
30
+ >
31
+ <Sun className="theme-sun" size={16} aria-hidden="true" />
32
+ <Moon className="theme-moon" size={16} aria-hidden="true" />
33
+ </button>
34
+ );
35
+ }
@@ -0,0 +1,83 @@
1
+ import { db } from "./db.mjs";
2
+ export async function agent(route, options = {}) {
3
+ const base = process.env.AGENT_URL || "http://127.0.0.1:4001";
4
+ const url = new URL(base);
5
+ if (
6
+ url.protocol !== "https:" &&
7
+ !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)
8
+ )
9
+ throw Error("Remote agents require HTTPS");
10
+ if ((process.env.AGENT_TOKEN?.length || 0) < 32)
11
+ throw Error("Configure AGENT_TOKEN");
12
+ const response = await fetch(base + route, {
13
+ ...options,
14
+ headers: {
15
+ Authorization: `Bearer ${process.env.AGENT_TOKEN}`,
16
+ "Content-Type": "application/json",
17
+ ...options.headers,
18
+ },
19
+ cache: "no-store",
20
+ signal: AbortSignal.timeout(30000),
21
+ });
22
+ const body = await response.json();
23
+ if (!response.ok)
24
+ throw Object.assign(Error(body.error || "Agent request failed"), {
25
+ status: response.status,
26
+ });
27
+ await db.query("UPDATE agents SET last_seen=now() WHERE name='primary'");
28
+ return body;
29
+ }
30
+ export async function reconcile(project) {
31
+ const remote = await agent(`/projects/${project.id}`);
32
+ const client = await db.connect();
33
+ try {
34
+ await client.query("BEGIN");
35
+ await client.query(
36
+ "UPDATE projects SET status=$2,port=$3,folder_path=$4,updated_at=CASE WHEN status IS DISTINCT FROM $2 THEN now() ELSE updated_at END WHERE id=$1",
37
+ [project.id, remote.status, remote.port, remote.folder_path],
38
+ );
39
+ await client.query(
40
+ "UPDATE deployments SET status='failed',error='Agent did not accept this deployment. Please retry.',finished_at=now() WHERE project_id=$1 AND status IN ('queued','deploying') AND id IS DISTINCT FROM $2::uuid AND created_at < now() - interval '2 minutes'",
41
+ [project.id, remote.deployment_id || null],
42
+ );
43
+ if (remote.deployment_id)
44
+ await client.query(
45
+ "UPDATE deployments SET status=$2,error=$3,logs_ref=$4,finished_at=CASE WHEN $2 IN ('running','failed') THEN COALESCE(finished_at,now()) ELSE NULL END WHERE id=$1 AND project_id=$5",
46
+ [
47
+ remote.deployment_id,
48
+ remote.status,
49
+ remote.error || null,
50
+ `agent:${project.id}`,
51
+ project.id,
52
+ ],
53
+ );
54
+ if (remote.tunnel) {
55
+ const t = remote.tunnel;
56
+ await client.query(
57
+ "INSERT INTO tunnels(project_id,tunnel_id,tunnel_name,credentials_path,config_path) VALUES($1,$2,$3,$4,$5) ON CONFLICT(project_id) DO UPDATE SET tunnel_id=$2,tunnel_name=$3,credentials_path=$4,config_path=$5",
58
+ [
59
+ project.id,
60
+ t.tunnel_id,
61
+ t.tunnel_name,
62
+ t.credentials_path,
63
+ t.config_path,
64
+ ],
65
+ );
66
+ }
67
+ await client.query("COMMIT");
68
+ } catch (error) {
69
+ await client.query("ROLLBACK");
70
+ throw error;
71
+ } finally {
72
+ client.release();
73
+ }
74
+ return {
75
+ ...project,
76
+ status: remote.status,
77
+ port: remote.port,
78
+ folder_path: remote.folder_path,
79
+ tunnel: remote.tunnel,
80
+ error: remote.error,
81
+ has_upload: remote.has_upload,
82
+ };
83
+ }
@@ -0,0 +1,48 @@
1
+ import { SignJWT, jwtVerify } from "jose";
2
+ import { cookies } from "next/headers";
3
+ import { db } from "./db.mjs";
4
+ function key() {
5
+ if ((process.env.JWT_SECRET?.length || 0) < 32)
6
+ throw Error("JWT_SECRET must contain at least 32 characters");
7
+ return new TextEncoder().encode(process.env.JWT_SECRET);
8
+ }
9
+ export async function session() {
10
+ const token = (await cookies()).get("naviyra_session")?.value;
11
+ if (!token) return null;
12
+ let payload;
13
+ try {
14
+ ({ payload } = await jwtVerify(token, key(), {
15
+ algorithms: ["HS256"],
16
+ issuer: "naviyra",
17
+ audience: "naviyra-panel",
18
+ }));
19
+ } catch {
20
+ return null;
21
+ }
22
+ return (
23
+ (
24
+ await db.query("SELECT id,email,role FROM users WHERE id=$1", [
25
+ payload.sub,
26
+ ])
27
+ ).rows[0] || null
28
+ );
29
+ }
30
+ export async function signIn(user) {
31
+ const token = await new SignJWT({})
32
+ .setProtectedHeader({ alg: "HS256" })
33
+ .setSubject(user.id)
34
+ .setIssuer("naviyra")
35
+ .setAudience("naviyra-panel")
36
+ .setIssuedAt()
37
+ .setExpirationTime("8h")
38
+ .sign(key());
39
+ (await cookies()).set("naviyra_session", token, {
40
+ httpOnly: true,
41
+ sameSite: "strict",
42
+ secure:
43
+ process.env.PUBLIC_ORIGIN?.startsWith("https://") ||
44
+ process.env.NODE_ENV === "production",
45
+ path: "/",
46
+ maxAge: 28800,
47
+ });
48
+ }