@open-nodo/cli 0.6.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.
@@ -0,0 +1,87 @@
1
+ /** Reading and writing the files a scaffolded app owns: .env, .npmrc, .gitignore, nodo.modules.json. */
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { die } from "./console.js";
5
+
6
+ const GH_PACKAGES = "https://npm.pkg.github.com";
7
+
8
+ /** Upsert a KEY=value line in a dotenv string (preserves other lines / comments). */
9
+ export function setEnvLine(envText, key, value) {
10
+ const line = `${key}=${value}`;
11
+ const re = new RegExp(`^${key}=.*$`, "m");
12
+ if (re.test(envText)) return envText.replace(re, line);
13
+ const base = envText.endsWith("\n") || envText === "" ? envText : `${envText}\n`;
14
+ return `${base}${line}\n`;
15
+ }
16
+
17
+ export function parseModuleSpec(spec) {
18
+ const at = spec.lastIndexOf("@");
19
+ if (at > 0) {
20
+ const version = spec.slice(at + 1);
21
+ return { key: spec.slice(0, at), version: version || null };
22
+ }
23
+ return { key: spec, version: null };
24
+ }
25
+
26
+ export function readModulesJson(dir) {
27
+ const p = join(dir, "nodo.modules.json");
28
+ if (!existsSync(p)) return { modules: [] };
29
+ try {
30
+ const j = JSON.parse(readFileSync(p, "utf8"));
31
+ return { ...j, modules: Array.isArray(j.modules) ? j.modules : [] };
32
+ } catch {
33
+ return die(`nodo.modules.json is not valid JSON — fix or remove it, then re-run.`);
34
+ }
35
+ }
36
+
37
+ export function writeModulesJson(dir, modules) {
38
+ const existing = readModulesJson(dir);
39
+ writeFileSync(
40
+ join(dir, "nodo.modules.json"),
41
+ JSON.stringify({ ...existing, modules }, null, 2) + "\n",
42
+ );
43
+ }
44
+
45
+ /** Write project .npmrc for GitHub Packages; use env token expansion (no secret in git). */
46
+ export function writeProjectNpmrc(dir) {
47
+ const lines = [
48
+ "# @open-nodo/* — GitHub Packages. Auth required even for reads.",
49
+ "# Set NODE_AUTH_TOKEN (GitHub PAT with read:packages) in your environment.",
50
+ "# export NODE_AUTH_TOKEN=$(gh auth token)",
51
+ `@open-nodo:registry=${GH_PACKAGES}`,
52
+ `//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}`,
53
+ "",
54
+ ];
55
+ writeFileSync(join(dir, ".npmrc"), lines.join("\n"));
56
+ }
57
+
58
+ /**
59
+ * Guarantee the scaffold ignores its own .env before the first commit — it holds
60
+ * NODO_SELECTION_TOKEN, a one-shot registration credential.
61
+ *
62
+ * The check is LINE-ANCHORED on purpose: a substring test for ".env" is already
63
+ * satisfied by the template's `.env*.local` pattern, which does not match a bare
64
+ * `.env`, so the token used to land in the initial commit.
65
+ *
66
+ * .npmrc deliberately stays tracked (it is the registry map, and auth comes from
67
+ * env expansion, not a committed secret).
68
+ */
69
+ export function ensureGitignoreHidesEnv(dir) {
70
+ const gi = join(dir, ".gitignore");
71
+ const lines = existsSync(gi) ? readFileSync(gi, "utf8") : "";
72
+ const missing = [".env", ".env.local"].filter((p) => !new RegExp(`^${p}$`, "m").test(lines));
73
+ if (missing.length === 0) return;
74
+ const base = lines === "" || lines.endsWith("\n") ? lines : `${lines}\n`;
75
+ writeFileSync(gi, `${base}${missing.join("\n")}\n`);
76
+ }
77
+
78
+ /** Decode a selection token's payload. Unverified by design — the rail re-validates on register. */
79
+ export function decodeToken(token) {
80
+ const parts = token.split(".");
81
+ if (parts.length < 2) die("That doesn't look like a selection token.");
82
+ try {
83
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
84
+ } catch {
85
+ return die("Could not read the selection token.");
86
+ }
87
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "@open-nodo/cli": "0.6.0",
3
+ "@open-nodo/codec-501c3-us": "0.1.1",
4
+ "@open-nodo/codec-anbi-nl": "0.1.1",
5
+ "@open-nodo/codec-asociacion-es": "0.1.1",
6
+ "@open-nodo/codec-assoc-fr": "0.1.1",
7
+ "@open-nodo/codec-charity-ie": "0.1.1",
8
+ "@open-nodo/codec-charity-uk": "0.1.1",
9
+ "@open-nodo/codec-onlus": "0.1.1",
10
+ "@open-nodo/codec-verein-de": "0.1.1",
11
+ "@open-nodo/compliance-core": "0.1.1",
12
+ "@open-nodo/core-db": "0.1.1",
13
+ "@open-nodo/format": "0.2.0",
14
+ "@open-nodo/module-accounting": "0.4.0",
15
+ "@open-nodo/module-beneficiaries": "0.1.2",
16
+ "@open-nodo/module-donors": "0.1.2",
17
+ "@open-nodo/module-members": "0.1.1",
18
+ "@open-nodo/module-patrimony": "0.6.0",
19
+ "@open-nodo/money": "0.1.1",
20
+ "@open-nodo/onlus-compliance": "1.0.1",
21
+ "@open-nodo/org-navigation": "0.2.0",
22
+ "@open-nodo/org-profile": "0.3.0",
23
+ "@open-nodo/pii-crypto": "0.2.0",
24
+ "@open-nodo/protocol": "0.3.0",
25
+ "@open-nodo/rendiconto": "0.1.1",
26
+ "@open-nodo/role-catalog": "0.2.0",
27
+ "@open-nodo/sdk": "0.1.3"
28
+ }
@@ -0,0 +1,15 @@
1
+ # Connect this app to the Nodo rail. Unset → runs standalone (no rail, no check-in).
2
+ NODO_RAIL_URL=https://nodo-rail.vercel.app/v1
3
+
4
+ # One-time selection token from the /join flow (carries the modules you picked in the
5
+ # catalog). Preferred over NODO_REGISTRATION_TOKEN when both are set. Needed only until
6
+ # the first successful registration (the instance id is then persisted in the keystore).
7
+ NODO_SELECTION_TOKEN=
8
+
9
+ # One-time bootstrap token from the rail operator/console. Used when NODO_SELECTION_TOKEN
10
+ # is unset; still supported.
11
+ NODO_REGISTRATION_TOKEN=
12
+
13
+ # Where to persist the Ed25519 instance keypair + cached check-in. Must be a
14
+ # writable, durable path (a mounted volume in a container).
15
+ NODO_KEYSTORE_PATH=./.nodo/keystore.json
@@ -0,0 +1,35 @@
1
+ # Open Nodo app template
2
+
3
+ This starter is bundled into `@open-nodo/cli`. A scaffolded repository owns its
4
+ application and data while the SDK handles registration, signed check-in, and
5
+ module entitlements.
6
+
7
+ ## Run
8
+
9
+ 1. Copy `.env.example` to `.env.local` and set the rail URL and one-time
10
+ registration or selection token.
11
+ 2. Set `NODE_AUTH_TOKEN` with `read:packages` while packages resolve from GitHub
12
+ Packages.
13
+ 3. Run `npm install` and `npm run dev`.
14
+
15
+ With no rail URL, the starter runs in standalone mode.
16
+
17
+ ## Integration points
18
+
19
+ - `src/lib/keystore.ts` stores the Ed25519 key, instance ID, and cached check-in
20
+ at `NODO_KEYSTORE_PATH`.
21
+ - `src/lib/nodo.ts` integrates `@open-nodo/sdk`.
22
+ - `instrumentation.ts` performs a time-boxed best-effort boot check-in.
23
+ - `isNodoModuleEnabled("<module-key>")` gates each optional module mount point.
24
+
25
+ The file keystore requires a durable writable volume. A stateless serverless
26
+ deployment needs a persistent keystore adapter and is not supported by this
27
+ starter.
28
+
29
+ ```bash
30
+ npm run build
31
+ NODO_RAIL_URL=… NODO_REGISTRATION_TOKEN=… NODO_KEYSTORE_PATH=/data/keystore.json \
32
+ node .next/standalone/templates/app/server.js
33
+ ```
34
+
35
+ Raw books stay in the instance. Licensed under AGPL-3.0-or-later.
@@ -0,0 +1,7 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ export const dynamic = "force-dynamic";
4
+
5
+ export function GET(): NextResponse {
6
+ return NextResponse.json({ status: "ok", service: "nodo-app" });
7
+ }
@@ -0,0 +1,107 @@
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ Nodo design tokens — the same palette the Nodo platform ships (neutral
3
+ slate + emerald primary), stated as HSL channels and consumed via
4
+ hsl(var(--token)). Light is :root, dark follows the OS: a starter app
5
+ should not need a theme library to look right at 2am.
6
+
7
+ These are yours to edit. Every value below is a token, so restyling the
8
+ whole app means changing this block, not hunting inline colours.
9
+ ───────────────────────────────────────────────────────────────────────── */
10
+ :root {
11
+ --background: 210 20% 99%;
12
+ --foreground: 222 24% 12%;
13
+ --card: 0 0% 100%;
14
+ --muted: 210 16% 95%;
15
+ --muted-foreground: 215 14% 40%;
16
+ --primary: 158 66% 30%;
17
+ --primary-text: 158 72% 27%;
18
+ --accent: 158 60% 94%;
19
+ --border: 214 20% 89%;
20
+ /* Control boundaries clear 3:1 against every surface (WCAG 1.4.11). */
21
+ --input: 214 16% 56%;
22
+ --ring: 158 64% 37%;
23
+ --radius: 0.85rem;
24
+ --radius-sm: 0.6rem;
25
+ color-scheme: light;
26
+ }
27
+
28
+ @media (prefers-color-scheme: dark) {
29
+ :root {
30
+ --background: 222 26% 6%;
31
+ --foreground: 210 22% 92%;
32
+ --card: 222 22% 9%;
33
+ --muted: 222 16% 14%;
34
+ --muted-foreground: 215 16% 60%;
35
+ --primary: 158 64% 45%;
36
+ --primary-text: 158 64% 56%;
37
+ --accent: 158 48% 14%;
38
+ --border: 222 14% 18%;
39
+ --input: 222 12% 45%;
40
+ --ring: 158 64% 46%;
41
+ color-scheme: dark;
42
+ }
43
+ }
44
+
45
+ * { box-sizing: border-box; }
46
+ html { -webkit-text-size-adjust: 100%; }
47
+ body {
48
+ margin: 0;
49
+ background: hsl(var(--background));
50
+ color: hsl(var(--foreground));
51
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
52
+ line-height: 1.55;
53
+ -webkit-font-smoothing: antialiased;
54
+ }
55
+ h1, h2 { line-height: 1.15; letter-spacing: -0.02em; margin: 0; }
56
+ p { margin: 0; }
57
+ code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
58
+
59
+ :where(a[href], button, [tabindex]:not([tabindex="-1"])):focus-visible {
60
+ outline: 2px solid hsl(var(--ring));
61
+ outline-offset: 2px;
62
+ border-radius: var(--radius-sm);
63
+ }
64
+
65
+ .shell { max-width: 720px; margin: 0 auto; padding: 64px 24px; }
66
+
67
+ .brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
68
+ .brand-dot {
69
+ width: 12px; height: 12px; border-radius: 50%;
70
+ background: hsl(var(--primary)); box-shadow: 0 0 0 4px hsl(var(--primary) / 0.18);
71
+ }
72
+ .brand h1 { font-size: 28px; }
73
+ .lead { color: hsl(var(--muted-foreground)); margin-bottom: 28px; }
74
+
75
+ .card {
76
+ background: hsl(var(--card));
77
+ border: 1px solid hsl(var(--border));
78
+ border-radius: var(--radius);
79
+ padding: 24px;
80
+ margin-bottom: 16px;
81
+ }
82
+ .card h2 { font-size: 16px; margin-bottom: 4px; }
83
+ .muted { color: hsl(var(--muted-foreground)); }
84
+
85
+ .row {
86
+ display: flex; justify-content: space-between; gap: 16px;
87
+ padding: 8px 0; border-bottom: 1px solid hsl(var(--border));
88
+ }
89
+ .row:last-child { border-bottom: 0; }
90
+ .row .value { font-weight: 600; }
91
+ .row .value code { font-size: 12px; }
92
+ .yes { color: hsl(var(--primary-text)); }
93
+
94
+ .modules { display: flex; flex-wrap: wrap; gap: 8px; }
95
+ .module {
96
+ font-size: 13px; font-family: ui-monospace, Menlo, monospace;
97
+ border: 1px solid hsl(var(--border)); color: hsl(var(--muted-foreground));
98
+ border-radius: 8px; padding: 4px 10px;
99
+ }
100
+ .module.active { border-color: hsl(var(--primary) / 0.35); color: hsl(var(--primary-text)); }
101
+
102
+ .hint { font-size: 13px; margin-top: 16px; }
103
+
104
+ @media (max-width: 560px) {
105
+ .shell { padding: 40px 18px; }
106
+ .row { flex-direction: column; gap: 2px; }
107
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Metadata } from "next";
3
+ import "./globals.css";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Nodo app",
7
+ description: "A Nodo starter app connected to the rail.",
8
+ };
9
+
10
+ export default function RootLayout({ children }: { children: ReactNode }) {
11
+ return (
12
+ <html lang="en">
13
+ <body>{children}</body>
14
+ </html>
15
+ );
16
+ }
@@ -0,0 +1,87 @@
1
+ import { nodoStatus } from "@/src/lib/nodo";
2
+
3
+ export const dynamic = "force-dynamic";
4
+
5
+ function Row({ label, value }: { label: string; value: React.ReactNode }) {
6
+ return (
7
+ <div className="row">
8
+ <span className="muted">{label}</span>
9
+ <span className="value">{value}</span>
10
+ </div>
11
+ );
12
+ }
13
+
14
+ export default async function Home() {
15
+ const status = await nodoStatus();
16
+ const profile = status.enabled
17
+ ? (status.profile as { name?: string; legalForm?: string; hierarchy?: string } | null)
18
+ : null;
19
+
20
+ return (
21
+ <main className="shell">
22
+ <div className="brand">
23
+ <span className="brand-dot" aria-hidden />
24
+ <h1>Nodo app</h1>
25
+ </div>
26
+ <p className="lead">
27
+ An app you own, connected to the Nodo rail. Accounting, registers and day-to-day
28
+ operations switch on according to your entitlements.
29
+ </p>
30
+
31
+ {!status.enabled ? (
32
+ <div className="card">
33
+ <p className="muted">
34
+ Rail not configured. Set <code>NODO_RAIL_URL</code> (and{" "}
35
+ <code>NODO_REGISTRATION_TOKEN</code> on first boot) to connect this app.
36
+ </p>
37
+ </div>
38
+ ) : (
39
+ <>
40
+ <div className="card">
41
+ <h2>Connection</h2>
42
+ <Row label="Rail" value={<code>{status.railUrl}</code>} />
43
+ <Row
44
+ label="Registered"
45
+ value={status.registered ? <span className="yes">yes</span> : "no"}
46
+ />
47
+ {status.instanceId && <Row label="Instance" value={<code>{status.instanceId}</code>} />}
48
+ <Row
49
+ label="Last check-in"
50
+ value={
51
+ status.lastCheckinAtMs ? new Date(status.lastCheckinAtMs).toLocaleString() : "never"
52
+ }
53
+ />
54
+ </div>
55
+
56
+ {profile && (
57
+ <div className="card">
58
+ <h2>Profile</h2>
59
+ <Row label="Organisation" value={profile.name ?? "—"} />
60
+ <Row label="Legal form" value={profile.legalForm ?? "—"} />
61
+ <Row label="Hierarchy" value={profile.hierarchy ?? "—"} />
62
+ </div>
63
+ )}
64
+
65
+ <div className="card">
66
+ <h2>Active modules ({status.entitlements.filter((e) => e.active).length})</h2>
67
+ {status.entitlements.length === 0 ? (
68
+ <p className="muted">No modules enabled. Your operator turns them on from the rail.</p>
69
+ ) : (
70
+ <div className="modules">
71
+ {status.entitlements.map((e) => (
72
+ <span key={e.module} className={`module${e.active ? " active" : ""}`}>
73
+ {e.module}
74
+ </span>
75
+ ))}
76
+ </div>
77
+ )}
78
+ <p className="muted hint">
79
+ {/* Each optional mount point is gated by isNodoModuleEnabled("<module-key>"). */}
80
+ Enabled packages mount here once their entitlement is active.
81
+ </p>
82
+ </div>
83
+ </>
84
+ )}
85
+ </main>
86
+ );
87
+ }
@@ -0,0 +1,12 @@
1
+ /node_modules
2
+ /.next/
3
+ /out/
4
+ *.tsbuildinfo
5
+ next-env.d.ts
6
+ # `nodo init` writes .env with NODO_SELECTION_TOKEN — a one-shot registration
7
+ # credential. It must never reach the first commit. `.env*.local` alone does
8
+ # NOT cover a bare `.env`.
9
+ .env
10
+ .env*.local
11
+ .vercel
12
+ /.nodo/
@@ -0,0 +1,10 @@
1
+ // Next.js instrumentation — runs once on server boot. Registers + checks into the
2
+ // rail (best-effort, time-boxed so a rail outage never blocks startup).
3
+ export async function register(): Promise<void> {
4
+ if (process.env["NEXT_RUNTIME"] !== "nodejs") return;
5
+ const { nodoCheckinOnBoot } = await import("./src/lib/nodo");
6
+ await Promise.race([
7
+ nodoCheckinOnBoot(),
8
+ new Promise<void>((resolve) => setTimeout(resolve, 3000)),
9
+ ]);
10
+ }
@@ -0,0 +1,6 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/types/routes.d.ts";
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,12 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ // Host-portable: `output: "standalone"` emits a self-contained .next/standalone
4
+ // (server.js + bundled deps incl. @open-nodo/sdk) that runs as `node server.js` in any
5
+ // container — the right home for the file keystore (mount a volume at NODO_KEYSTORE_PATH).
6
+ // Vercel serverless's read-only FS doesn't suit the file keystore; use a container.
7
+ const nextConfig: NextConfig = {
8
+ poweredByHeader: false,
9
+ output: "standalone",
10
+ };
11
+
12
+ export default nextConfig;
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@open-nodo/app-template",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Self-hosted Open Nodo starter with signed registration and module entitlement checks.",
6
+ "license": "AGPL-3.0-or-later",
7
+ "scripts": {
8
+ "dev": "next dev -p 3200",
9
+ "build": "next build",
10
+ "start": "next start -p 3200",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "next": "^16.2.12",
15
+ "react": "^19.2.8",
16
+ "react-dom": "^19.2.8",
17
+ "@open-nodo/sdk": "^0.1.3"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^20.17.0",
21
+ "@types/react": "^19.2.0",
22
+ "@types/react-dom": "^19.2.0",
23
+ "typescript": "^5.7.2"
24
+ },
25
+ "overrides": {
26
+ "postcss": "^8.5.24",
27
+ "sharp": "^0.35.3"
28
+ }
29
+ }
@@ -0,0 +1,71 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import type { KeyStore, Jwk, CachedCheckin } from "@open-nodo/sdk";
4
+
5
+ // A durable KeyStore backed by a single JSON file (NODO_KEYSTORE_PATH). Keeps the
6
+ // template dependency-free (no DB). Persists the Ed25519 private key, the instance
7
+ // id, and the cached check-in so identity + entitlements survive restarts. Swap for
8
+ // a DB-backed store at scale.
9
+
10
+ type Persisted = {
11
+ privateKeyJwk?: Jwk;
12
+ instanceId?: string;
13
+ cachedCheckin?: CachedCheckin;
14
+ };
15
+
16
+ const PATH = process.env["NODO_KEYSTORE_PATH"] ?? "./.nodo/keystore.json";
17
+
18
+ async function load(): Promise<Persisted> {
19
+ try {
20
+ return JSON.parse(
21
+ await readFile(/* turbopackIgnore: true */ PATH, "utf8"),
22
+ ) as Persisted;
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ async function save(patch: Partial<Persisted>): Promise<void> {
29
+ const next = { ...(await load()), ...patch };
30
+ // The file holds a raw Ed25519 PRIVATE key: owner-only permissions, explicitly —
31
+ // the process umask must not get a vote (0755/0644 defaults leave it world-readable).
32
+ await mkdir(dirname(/* turbopackIgnore: true */ PATH), { recursive: true, mode: 0o700 });
33
+ // Atomic write: writeFile alone isn't atomic, so a crash mid-write would
34
+ // truncate the keystore and lose the Ed25519 private key (unrecoverable —
35
+ // re-registration needs a fresh token). Write a temp file then rename; a
36
+ // rename on the same filesystem is atomic, so any reader/crash sees either
37
+ // the intact old file or the fully-written new one. (Concurrent writers from
38
+ // separate processes can still last-write-wins; that's out of scope for this
39
+ // single-instance dev keystore — swap in a DB-backed store at scale.)
40
+ const tmp = `${PATH}.${process.pid}.tmp`;
41
+ await writeFile(
42
+ /* turbopackIgnore: true */ tmp,
43
+ JSON.stringify(next, null, 2),
44
+ { encoding: "utf8", mode: 0o600 },
45
+ );
46
+ await rename(
47
+ /* turbopackIgnore: true */ tmp,
48
+ /* turbopackIgnore: true */ PATH,
49
+ );
50
+ }
51
+
52
+ export const fileKeyStore: KeyStore = {
53
+ async getPrivateKeyJwk() {
54
+ return (await load()).privateKeyJwk ?? null;
55
+ },
56
+ async setPrivateKeyJwk(jwk) {
57
+ await save({ privateKeyJwk: jwk });
58
+ },
59
+ async getInstanceId() {
60
+ return (await load()).instanceId ?? null;
61
+ },
62
+ async setInstanceId(id) {
63
+ await save({ instanceId: id });
64
+ },
65
+ async getCachedCheckin() {
66
+ return (await load()).cachedCheckin ?? null;
67
+ },
68
+ async setCachedCheckin(cached) {
69
+ await save({ cachedCheckin: cached });
70
+ },
71
+ };
@@ -0,0 +1,94 @@
1
+ import { createNodoInstance, type NodoInstance } from "@open-nodo/sdk";
2
+ import { fileKeyStore } from "./keystore";
3
+
4
+ // Rail integration for the starter app. Entirely OPT-IN: with NODO_RAIL_URL unset,
5
+ // every function is a no-op and the app runs standalone. When set, the app registers
6
+ // as a `self` instance and checks in (identity + module entitlements). Best-effort —
7
+ // the rail is non-critical and never throws into a request path.
8
+
9
+ const railUrl = process.env["NODO_RAIL_URL"];
10
+ const regToken = process.env["NODO_REGISTRATION_TOKEN"];
11
+ const selectionToken = process.env["NODO_SELECTION_TOKEN"];
12
+ const APP_VERSION = process.env["npm_package_version"] ?? "0.1.0";
13
+
14
+ export function isNodoEnabled(): boolean {
15
+ return Boolean(railUrl);
16
+ }
17
+
18
+ const g = globalThis as unknown as { __nodoInstance?: NodoInstance };
19
+
20
+ function getInstance(): NodoInstance {
21
+ if (!railUrl) throw new Error("NODO_RAIL_URL is not set");
22
+ g.__nodoInstance ??= createNodoInstance({ railUrl, storage: fileKeyStore });
23
+ return g.__nodoInstance;
24
+ }
25
+
26
+ /** Register on first boot (idempotent) + check in. Best-effort; swallows errors. */
27
+ export async function nodoCheckinOnBoot(): Promise<void> {
28
+ if (!railUrl) return;
29
+ try {
30
+ const nodo = getInstance();
31
+ if (!(await fileKeyStore.getInstanceId())) {
32
+ try {
33
+ if (selectionToken) {
34
+ // Preferred: one-time selection token from the /join flow.
35
+ await nodo.registerWithSelectionToken(selectionToken);
36
+ } else if (regToken) {
37
+ await nodo.registerOnce(regToken, {
38
+ kind: "self",
39
+ hostname: process.env["VERCEL_URL"] ?? "nodo-app",
40
+ });
41
+ }
42
+ } catch {
43
+ // token already used / instance already registered — fine.
44
+ }
45
+ }
46
+ await nodo.checkin({ appVersion: APP_VERSION, modulesRunning: [] });
47
+ } catch (err) {
48
+ console.error(
49
+ JSON.stringify({
50
+ level: "warn",
51
+ msg: "nodo_checkin_failed",
52
+ error: err instanceof Error ? err.message : String(err),
53
+ }),
54
+ );
55
+ }
56
+ }
57
+
58
+ /** Offline-grace-aware module gate — use it to mount feature modules on entitlement. */
59
+ export function isNodoModuleEnabled(key: string): boolean {
60
+ if (!railUrl || !g.__nodoInstance) return false;
61
+ return g.__nodoInstance.isModuleEnabled(key);
62
+ }
63
+
64
+ export type NodoStatus =
65
+ | { enabled: false }
66
+ | {
67
+ enabled: true;
68
+ railUrl: string;
69
+ registered: boolean;
70
+ instanceId: string | null;
71
+ profile: unknown;
72
+ entitlements: Array<{ module: string; plan: string; active: boolean }>;
73
+ lastCheckinAtMs: number | null;
74
+ };
75
+
76
+ /** Non-sensitive status for the dashboard, read from the cached check-in. */
77
+ export async function nodoStatus(): Promise<NodoStatus> {
78
+ if (!railUrl) return { enabled: false };
79
+ const [instanceId, cached] = await Promise.all([
80
+ fileKeyStore.getInstanceId(),
81
+ fileKeyStore.getCachedCheckin(),
82
+ ]);
83
+ return {
84
+ enabled: true,
85
+ railUrl,
86
+ registered: Boolean(instanceId),
87
+ instanceId,
88
+ profile:
89
+ ((cached?.response as { identity?: { profile?: unknown } } | undefined)?.identity)?.profile ?? null,
90
+ entitlements:
91
+ cached?.response.entitlements.map((e) => ({ module: e.module_key, plan: e.plan, active: e.active })) ?? [],
92
+ lastCheckinAtMs: cached?.fetchedAtMs ?? null,
93
+ };
94
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": [
5
+ "dom",
6
+ "dom.iterable",
7
+ "esnext"
8
+ ],
9
+ "allowJs": true,
10
+ "skipLibCheck": true,
11
+ "strict": true,
12
+ "noEmit": true,
13
+ "esModuleInterop": true,
14
+ "module": "esnext",
15
+ "moduleResolution": "bundler",
16
+ "resolveJsonModule": true,
17
+ "isolatedModules": true,
18
+ "jsx": "react-jsx",
19
+ "incremental": true,
20
+ "plugins": [
21
+ {
22
+ "name": "next"
23
+ }
24
+ ],
25
+ "paths": {
26
+ "@/*": [
27
+ "./*"
28
+ ]
29
+ }
30
+ },
31
+ "include": [
32
+ "next-env.d.ts",
33
+ "**/*.ts",
34
+ "**/*.tsx",
35
+ ".next/types/**/*.ts",
36
+ ".next/dev/types/**/*.ts"
37
+ ],
38
+ "exclude": [
39
+ "node_modules"
40
+ ]
41
+ }