This commit is contained in:
Harivansh Rathi 2026-04-01 03:45:34 +00:00
parent 4f174ec3a8
commit db1dea097f
81 changed files with 6263 additions and 545 deletions

3
.gitignore vendored
View file

@ -3,3 +3,6 @@ dist/
.DS_Store
npm-debug.log*
.codex/
.turbo/
coverage/
apps/web/.next/

View file

@ -22,3 +22,16 @@ Read these in order:
- the control plane is the system of record.
- the NAS node serves bytes directly whenever possible.
- Nextcloud is an optional cloud/web adapter, not the product center.
## Monorepo
- `apps/web`: Next.js control-plane UI
- `apps/control-plane`: Go control-plane service
- `apps/node-agent`: Go NAS runtime / WebDAV node
- `apps/nextcloud-app`: optional Nextcloud adapter
- `packages/contracts`: canonical shared contracts
- `packages/sdk-ts`: TypeScript SDK surface for the web app
- `packages/ui`: shared React UI
- `infra/docker`: local Docker runtime
The root planning and delegation guide lives in [skeleton.md](/home/rathi/Documents/GitHub/betterNAS/skeleton.md).

View file

@ -0,0 +1,15 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY apps/control-plane ./apps/control-plane
WORKDIR /src/apps/control-plane
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/control-plane ./cmd/control-plane
FROM alpine:3.21
WORKDIR /app
COPY --from=build /out/control-plane /usr/local/bin/control-plane
EXPOSE 3000
CMD ["control-plane"]

View file

@ -0,0 +1,15 @@
# betterNAS Control Plane
Go service that owns the product control plane.
It is intentionally small for now:
- `GET /health`
- `GET /version`
- `POST /api/v1/nodes/register`
- `GET /api/v1/exports`
- `POST /api/v1/mount-profiles/issue`
- `POST /api/v1/cloud-profiles/issue`
The request and response shapes must follow the contracts in
[`packages/contracts`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts).

View file

@ -0,0 +1,103 @@
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"time"
)
type jsonObject map[string]any
func main() {
port := env("PORT", "8081")
startedAt := time.Now()
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, jsonObject{
"service": "control-plane",
"status": "ok",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"uptimeSeconds": int(time.Since(startedAt).Seconds()),
"nextcloud": jsonObject{
"configured": false,
"baseUrl": env("NEXTCLOUD_BASE_URL", ""),
"provider": "nextcloud",
},
})
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, jsonObject{
"service": "control-plane",
"version": env("BETTERNAS_VERSION", "0.1.0-dev"),
"apiVersion": "v1",
})
})
mux.HandleFunc("/api/v1/exports", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, []jsonObject{})
})
mux.HandleFunc("/api/v1/mount-profiles/issue", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, jsonObject{
"id": "dev-profile",
"exportId": "dev-export",
"protocol": "webdav",
"displayName": "Example export",
"mountUrl": env("BETTERNAS_EXAMPLE_MOUNT_URL", "http://localhost:8090/dav"),
"readonly": false,
"credentialMode": "session-token",
})
})
mux.HandleFunc("/api/v1/cloud-profiles/issue", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, jsonObject{
"id": "dev-cloud",
"exportId": "dev-export",
"provider": "nextcloud",
"baseUrl": env("NEXTCLOUD_BASE_URL", "http://localhost:8080"),
"path": "/apps/files/files",
})
})
mux.HandleFunc("/api/v1/nodes/register", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, jsonObject{
"id": "dev-node",
"machineId": "dev-machine",
"displayName": "Development NAS",
"agentVersion": "0.1.0-dev",
"status": "online",
"lastSeenAt": time.Now().UTC().Format(time.RFC3339),
"directAddress": env("BETTERNAS_NODE_DIRECT_ADDRESS", "http://localhost:8090"),
"relayAddress": nil,
})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
})
server := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("betterNAS control plane listening on :%s", port)
log.Fatal(server.ListenAndServe())
}
func env(key, fallback string) string {
value, ok := os.LookupEnv(key)
if !ok || value == "" {
return fallback
}
return value
}
func writeJSON(w http.ResponseWriter, statusCode int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(statusCode)
if err := json.NewEncoder(w).Encode(payload); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

View file

@ -0,0 +1,3 @@
module github.com/rathi/betternas/apps/control-plane
go 1.26.0

View file

@ -0,0 +1,12 @@
{
"name": "@betternas/control-plane",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "CGO_ENABLED=0 go run ./cmd/control-plane",
"build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/control-plane ./cmd/control-plane",
"lint": "CGO_ENABLED=0 go vet ./...",
"check-types": "CGO_ENABLED=0 go test ./...",
"test": "CGO_ENABLED=0 go test ./..."
}
}

View file

Before

Width:  |  Height:  |  Size: 292 B

After

Width:  |  Height:  |  Size: 292 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 292 B

After

Width:  |  Height:  |  Size: 292 B

Before After
Before After

10
apps/node-agent/README.md Normal file
View file

@ -0,0 +1,10 @@
# betterNAS Node Agent
Go service that runs on the NAS machine.
For the scaffold it does two things:
- serves `GET /health`
- serves a WebDAV export at `/dav/`
This is the first real storage-facing surface in the monorepo.

View file

@ -0,0 +1,46 @@
package main
import (
"log"
"net/http"
"os"
"time"
"golang.org/x/net/webdav"
)
func main() {
port := env("PORT", "8090")
exportPath := env("BETTERNAS_EXPORT_PATH", ".")
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
})
dav := &webdav.Handler{
Prefix: "/dav",
FileSystem: webdav.Dir(exportPath),
LockSystem: webdav.NewMemLS(),
}
mux.Handle("/dav/", dav)
server := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("betterNAS node agent serving %s on :%s", exportPath, port)
log.Fatal(server.ListenAndServe())
}
func env(key, fallback string) string {
value, ok := os.LookupEnv(key)
if !ok || value == "" {
return fallback
}
return value
}

5
apps/node-agent/go.mod Normal file
View file

@ -0,0 +1,5 @@
module github.com/rathi/betternas/apps/node-agent
go 1.26.0
require golang.org/x/net v0.46.0

2
apps/node-agent/go.sum Normal file
View file

@ -0,0 +1,2 @@
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=

View file

@ -0,0 +1,12 @@
{
"name": "@betternas/node-agent",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "CGO_ENABLED=0 go run ./cmd/node-agent",
"build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/node-agent ./cmd/node-agent",
"lint": "CGO_ENABLED=0 go vet ./...",
"check-types": "CGO_ENABLED=0 go test ./...",
"test": "CGO_ENABLED=0 go test ./..."
}
}

36
apps/web/.gitignore vendored Normal file
View file

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for commiting if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

13
apps/web/README.md Normal file
View file

@ -0,0 +1,13 @@
# betterNAS Web
Next.js control-plane UI for betterNAS.
Use this app for:
- admin and operator workflows
- node and export visibility
- issuing mount profiles
- later cloud-mode management
Do not move the product system of record into this app. It should stay a UI and
thin BFF layer over the Go control plane.

BIN
apps/web/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

50
apps/web/app/globals.css Normal file
View file

@ -0,0 +1,50 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
.imgDark {
display: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
.imgLight {
display: none;
}
.imgDark {
display: unset;
}
}

31
apps/web/app/layout.tsx Normal file
View file

@ -0,0 +1,31 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
});
export const metadata: Metadata = {
title: "betterNAS",
description: "Contract-first monorepo for NAS mounts and optional cloud access",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}

View file

@ -0,0 +1,77 @@
.page {
min-height: 100svh;
padding: 48px 24px 80px;
background:
radial-gradient(circle at top left, rgba(91, 186, 166, 0.18), transparent 28%),
linear-gradient(180deg, #f5fbfa 0%, #edf5f3 100%);
color: #10212d;
}
.hero {
max-width: 860px;
margin: 0 auto 32px;
padding: 32px;
border-radius: 28px;
background: linear-gradient(135deg, #123043 0%, #1d5466 100%);
color: #f7fbfc;
box-shadow: 0 24px 80px rgba(16, 33, 45, 0.14);
}
.eyebrow {
margin: 0 0 12px;
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.78;
}
.title {
margin: 0 0 12px;
font-size: clamp(2.25rem, 5vw, 4rem);
line-height: 0.98;
}
.copy {
margin: 0;
max-width: 64ch;
font-size: 1rem;
line-height: 1.7;
}
.grid {
max-width: 860px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 18px;
}
.card {
display: block;
padding: 22px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(18, 48, 67, 0.08);
box-shadow: 0 12px 30px rgba(18, 48, 67, 0.08);
color: inherit;
text-decoration: none;
}
.card h2 {
margin: 0 0 10px;
}
.card p {
margin: 0;
line-height: 1.6;
}
@media (max-width: 640px) {
.page {
padding-inline: 16px;
}
.hero {
padding: 24px;
}
}

45
apps/web/app/page.tsx Normal file
View file

@ -0,0 +1,45 @@
import { Card } from "@betternas/ui/card";
import styles from "./page.module.css";
const lanes = [
{
title: "NAS node",
body: "Runs on the storage machine. Exposes WebDAV, reports exports, and stays close to the bytes.",
},
{
title: "Control plane",
body: "Owns users, devices, nodes, grants, mount profiles, and cloud profiles.",
},
{
title: "Local device",
body: "Consumes mount profiles and uses Finder WebDAV flows before we ship a helper app.",
},
{
title: "Cloud layer",
body: "Keeps Nextcloud optional and thin for browser, mobile, and sharing flows.",
},
];
export default function Home() {
return (
<main className={styles.page}>
<section className={styles.hero}>
<p className={styles.eyebrow}>betterNAS monorepo</p>
<h1 className={styles.title}>Contract-first scaffold for NAS mounts and cloud mode.</h1>
<p className={styles.copy}>
The repo is organized so each system part can be built in parallel
without inventing new interfaces. The source of truth is the root
contract plus the shared contracts package.
</p>
</section>
<section className={styles.grid}>
{lanes.map((lane) => (
<Card key={lane.title} className={styles.card} title={lane.title} href="/#">
{lane.body}
</Card>
))}
</section>
</main>
);
}

View file

@ -0,0 +1,4 @@
import { nextJsConfig } from "@betternas/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default nextJsConfig;

6
apps/web/next.config.js Normal file
View file

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
export default nextConfig;

29
apps/web/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "@betternas/web",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
"check-types": "next typegen && tsc --noEmit"
},
"dependencies": {
"@betternas/sdk-ts": "*",
"@betternas/ui": "*",
"next": "16.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@betternas/eslint-config": "*",
"@betternas/typescript-config": "*",
"@types/node": "^22.15.3",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"eslint": "^9.39.1",
"typescript": "5.9.2"
}
}

18
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"extends": "@betternas/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [
{
"name": "next"
}
],
"strictNullChecks": true
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
"next.config.js"
],
"exclude": ["node_modules"]
}

View file

@ -49,7 +49,7 @@ The other three parts are execution surfaces:
| Part | Steal first | Write ourselves |
|---|---|---|
| NAS node | NixOS/Nix module patterns, existing WebDAV servers | node agent, export model, node registration flow |
| Control plane | existing repo scaffold, TS stack, Postgres/Redis patterns | product domain model, policy engine, mount/cloud APIs, registry |
| Control plane | Go stdlib routing, pgx/sqlc, go-redis/asynq, OpenAPI codegen | product domain model, policy engine, mount/cloud APIs, registry |
| Local device | Finder WebDAV mount, macOS Keychain, later maybe launch agent patterns | helper app, mount profile handling, auto-mount UX |
| Cloud/web layer | Nextcloud server, Nextcloud shell app, Nextcloud share/file UI, Nextcloud mobile references | betterNAS integration layer, mapping between product model and Nextcloud, later branded UI |
@ -72,10 +72,11 @@ The NAS node should be boring and reproducible.
## 2. Control plane
Start from:
- the current `exapps/control-plane` scaffold
- TypeScript
- Postgres
- Redis
- Go
- standard library routing first
- Postgres via `pgx` and `sqlc`
- Redis via `go-redis`
- OpenAPI-driven contracts
- standalone API mindset
Do not start by writing:
@ -151,7 +152,7 @@ This is high leverage, but should not block Phase A.
- Nix module patterns
### Control plane
- current TypeScript repo scaffold
- Go API service scaffold
- Postgres
- Redis
@ -193,7 +194,7 @@ This is high leverage, but should not block Phase A.
| Part | First scaffold |
|---|---|
| NAS node | Nix flake/module + WebDAV server service config |
| Control plane | current TypeScript control-plane service scaffold |
| Control plane | Go service + OpenAPI contract + Postgres/Redis adapters later |
| Local device | documented Finder mount flow, then lightweight helper app |
| Cloud/web layer | current Nextcloud scaffold and shell app |

View file

@ -74,6 +74,19 @@ Use these in this order:
- [`docs/04-cloud-web-layer.md`](/home/rathi/Documents/GitHub/betterNAS/docs/04-cloud-web-layer.md)
- [`docs/05-build-plan.md`](/home/rathi/Documents/GitHub/betterNAS/docs/05-build-plan.md)
## Repo lanes
The monorepo is split into these primary implementation lanes:
- [`apps/node-agent`](/home/rathi/Documents/GitHub/betterNAS/apps/node-agent)
- [`apps/control-plane`](/home/rathi/Documents/GitHub/betterNAS/apps/control-plane)
- [`apps/web`](/home/rathi/Documents/GitHub/betterNAS/apps/web)
- [`apps/nextcloud-app`](/home/rathi/Documents/GitHub/betterNAS/apps/nextcloud-app)
- [`packages/contracts`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts)
Every parallel task should primarily stay inside one of those lanes unless it is
an explicit contract task.
## The contract surface we need first
The first shared contract set should cover only the seams that let all four

View file

@ -32,27 +32,57 @@ The goal is simple: do not lose the external pieces that give us leverage.
## Control plane
### Current scaffold
- current control-plane seed
- path: [exapps/control-plane](/home/rathi/Documents/GitHub/betterNAS/exapps/control-plane)
- why: existing TypeScript seed for the first real backend
### Backend and infra references
- Fastify
- repo: https://github.com/fastify/fastify
- why: likely good fit for a TypeScript API backend
- Go routing enhancements
- docs: https://go.dev/blog/routing-enhancements
- why: best low-dependency baseline if we stay with the standard library
- `chi`
- repo: https://github.com/go-chi/chi
- why: thin stdlib-friendly router if we want middleware and route groups
- PostgreSQL
- docs: https://www.postgresql.org/docs/
- why: source of truth for product metadata
- `pgx`
- repo: https://github.com/jackc/pgx
- why: Postgres-first Go driver and toolkit
- `sqlc`
- repo: https://github.com/sqlc-dev/sqlc
- why: typed query generation for Go
- Redis
- docs: https://redis.io/docs/latest/
- why: cache, jobs, ephemeral coordination
### SSH access / gateway references
- `go-redis`
- repo: https://github.com/redis/go-redis
- why: primary Redis client for Go
- `asynq`
- repo: https://github.com/hibiken/asynq
- why: practical Redis-backed background jobs
- `koanf`
- repo: https://github.com/knadh/koanf
- why: layered config if the control plane grows beyond env-only config
- `envconfig`
- repo: https://github.com/kelseyhightower/envconfig
- why: small env-only config loader
- `log/slog`
- docs: https://pkg.go.dev/log/slog
- why: structured logging without extra dependencies
- `oapi-codegen`
- repo: https://github.com/oapi-codegen/oapi-codegen
- why: generate Go and TS surfaces from OpenAPI with less drift
### SSH access / gateway reference
- `sshpiper`
- repo: https://github.com/tg123/sshpiper
@ -76,6 +106,18 @@ The goal is simple: do not lose the external pieces that give us leverage.
- docs: https://developer.apple.com/documentation/
- why: Keychain, launch agents, desktop helpers, future native integration
- Keychain data protection
- docs: https://support.apple.com/guide/security/keychain-data-protection-secb0694df1a/web
- why: baseline secret-storage model for device credentials
- Finder Sync extensions
- docs: https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Finder.html
- why: future helper-app integration pattern if Finder UX grows
- WebDAV RFC 4918
- docs: https://www.rfc-editor.org/rfc/rfc4918
- why: protocol semantics and caveats
## Cloud / web layer
### Nextcloud server and app references

View file

@ -1,15 +0,0 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json tsconfig.base.json ./
COPY packages/contracts ./packages/contracts
COPY exapps/control-plane ./exapps/control-plane
RUN npm install
RUN npm run build
WORKDIR /app/exapps/control-plane
CMD ["npm", "run", "start"]

View file

@ -1,21 +0,0 @@
{
"name": "@betternas/control-plane",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@betternas/contracts": "file:../../packages/contracts"
},
"devDependencies": {
"@types/node": "^22.18.6",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}

View file

@ -1,14 +0,0 @@
import type { NextcloudBackendStatus } from "@betternas/contracts";
export class NextcloudBackendAdapter {
constructor(private readonly baseUrl: string) {}
describe(): NextcloudBackendStatus {
return {
configured: this.baseUrl.length > 0,
baseUrl: this.baseUrl,
provider: "nextcloud"
};
}
}

View file

@ -1,59 +0,0 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import {
CONTROL_PLANE_ROUTES,
type ControlPlaneHealthResponse,
type ControlPlaneVersionResponse
} from "@betternas/contracts";
import type { ControlPlaneConfig } from "./config.js";
import { NextcloudBackendAdapter } from "./adapters/nextcloud-backend.js";
export function createApp(config: ControlPlaneConfig) {
const startedAt = Date.now();
const nextcloudBackend = new NextcloudBackendAdapter(config.nextcloudBaseUrl);
return createServer((request, response) => {
if (request.method !== "GET" || !request.url) {
writeJson(response, 405, { error: "Method not allowed" });
return;
}
const url = new URL(request.url, "http://localhost");
if (url.pathname === CONTROL_PLANE_ROUTES.health) {
const payload: ControlPlaneHealthResponse = {
service: "control-plane",
status: "ok",
timestamp: new Date().toISOString(),
uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),
nextcloud: nextcloudBackend.describe()
};
writeJson(response, 200, payload);
return;
}
if (url.pathname === CONTROL_PLANE_ROUTES.version) {
const payload: ControlPlaneVersionResponse = {
service: "control-plane",
version: config.version,
apiVersion: "v1"
};
writeJson(response, 200, payload);
return;
}
writeJson(response, 404, {
error: "Not found"
});
});
}
function writeJson(response: ServerResponse<IncomingMessage>, statusCode: number, payload: unknown) {
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8"
});
response.end(JSON.stringify(payload));
}

View file

@ -1,25 +0,0 @@
export interface ControlPlaneConfig {
port: number;
version: string;
nextcloudBaseUrl: string;
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): ControlPlaneConfig {
const portValue = env.PORT ?? "3000";
const port = Number.parseInt(portValue, 10);
if (Number.isNaN(port)) {
throw new Error(`Invalid PORT value: ${portValue}`);
}
return {
port,
version: env.BETTERNAS_VERSION ?? "0.1.0-dev",
nextcloudBaseUrl: normalizeBaseUrl(env.NEXTCLOUD_BASE_URL ?? "http://nextcloud")
};
}
function normalizeBaseUrl(url: string): string {
return url.replace(/\/+$/, "");
}

View file

@ -1,10 +0,0 @@
import { createApp } from "./app.js";
import { loadConfig } from "./config.js";
const config = loadConfig();
const app = createApp(config);
app.listen(config.port, "0.0.0.0", () => {
console.log(`betterNAS control plane listening on port ${config.port}`);
});

View file

@ -1,11 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": [
"src/**/*.ts"
]
}

6
go.work Normal file
View file

@ -0,0 +1,6 @@
go 1.26.0
use (
./apps/control-plane
./apps/node-agent
)

1
go.work.sum Normal file
View file

@ -0,0 +1 @@
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=

View file

@ -26,8 +26,8 @@ services:
control-plane:
build:
context: ..
dockerfile: exapps/control-plane/Dockerfile
context: ../..
dockerfile: apps/control-plane/Dockerfile
environment:
PORT: 3000
BETTERNAS_VERSION: local-dev

5033
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,25 @@
{
"name": "betternas",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types",
"test": "turbo run test"
},
"devDependencies": {
"prettier": "^3.7.4",
"turbo": "^2.9.3",
"typescript": "5.9.2"
},
"engines": {
"node": ">=18"
},
"packageManager": "npm@10.9.7",
"workspaces": [
"packages/*",
"exapps/*"
],
"scripts": {
"build": "npm run build --workspace @betternas/contracts && npm run build --workspace @betternas/control-plane",
"typecheck": "npm run typecheck --workspace @betternas/contracts && npm run typecheck --workspace @betternas/control-plane",
"dev:control-plane": "npm run dev --workspace @betternas/control-plane"
"apps/*",
"packages/*"
]
}
}

View file

@ -12,6 +12,7 @@ Use it to keep the four product parts aligned:
## What belongs here
- OpenAPI source documents
- shared TypeScript types
- route constants
- JSON schemas for payloads we want to validate outside TypeScript
@ -28,6 +29,8 @@ Use it to keep the four product parts aligned:
- current runtime scaffold for health and version
- [`src/foundation.ts`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/src/foundation.ts)
- first product-level entities and route constants for node, mount, and cloud flows
- [`openapi/`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/openapi)
- language-neutral source documents for future SDK generation
- [`schemas/`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/schemas)
- JSON schema mirrors for the first shared entities

View file

@ -0,0 +1,307 @@
openapi: 3.1.0
info:
title: betterNAS Foundation API
version: 0.1.0
summary: Foundation contract for node registration, export metadata, and profile issuance.
servers:
- url: http://localhost:8081
paths:
/health:
get:
operationId: getControlPlaneHealth
responses:
"200":
description: Control-plane health
/version:
get:
operationId: getControlPlaneVersion
responses:
"200":
description: Control-plane version
/api/v1/nodes/register:
post:
operationId: registerNode
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NodeRegistrationRequest"
responses:
"200":
description: Registered node
content:
application/json:
schema:
$ref: "#/components/schemas/NasNode"
/api/v1/nodes/{nodeId}/heartbeat:
post:
operationId: recordNodeHeartbeat
parameters:
- in: path
name: nodeId
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NodeHeartbeatRequest"
responses:
"204":
description: Heartbeat accepted
/api/v1/exports:
get:
operationId: listExports
responses:
"200":
description: Export list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/StorageExport"
/api/v1/mount-profiles/issue:
post:
operationId: issueMountProfile
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MountProfileRequest"
responses:
"200":
description: Mount profile
content:
application/json:
schema:
$ref: "#/components/schemas/MountProfile"
/api/v1/cloud-profiles/issue:
post:
operationId: issueCloudProfile
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CloudProfileRequest"
responses:
"200":
description: Cloud profile
content:
application/json:
schema:
$ref: "#/components/schemas/CloudProfile"
components:
schemas:
NasNode:
type: object
required:
- id
- machineId
- displayName
- agentVersion
- status
- lastSeenAt
- directAddress
- relayAddress
properties:
id:
type: string
machineId:
type: string
displayName:
type: string
agentVersion:
type: string
status:
type: string
enum: [online, offline, degraded]
lastSeenAt:
type: string
directAddress:
type:
- string
- "null"
relayAddress:
type:
- string
- "null"
StorageExport:
type: object
required:
- id
- nasNodeId
- label
- path
- protocols
- capacityBytes
- tags
properties:
id:
type: string
nasNodeId:
type: string
label:
type: string
path:
type: string
protocols:
type: array
items:
type: string
enum: [webdav]
capacityBytes:
type:
- integer
- "null"
tags:
type: array
items:
type: string
MountProfile:
type: object
required:
- id
- exportId
- protocol
- displayName
- mountUrl
- readonly
- credentialMode
properties:
id:
type: string
exportId:
type: string
protocol:
type: string
enum: [webdav]
displayName:
type: string
mountUrl:
type: string
readonly:
type: boolean
credentialMode:
type: string
enum: [session-token, app-password]
CloudProfile:
type: object
required:
- id
- exportId
- provider
- baseUrl
- path
properties:
id:
type: string
exportId:
type: string
provider:
type: string
enum: [nextcloud]
baseUrl:
type: string
path:
type: string
StorageExportInput:
type: object
required:
- label
- path
- protocols
- capacityBytes
- tags
properties:
label:
type: string
path:
type: string
protocols:
type: array
items:
type: string
enum: [webdav]
capacityBytes:
type:
- integer
- "null"
tags:
type: array
items:
type: string
NodeRegistrationRequest:
type: object
required:
- machineId
- displayName
- agentVersion
- directAddress
- relayAddress
- exports
properties:
machineId:
type: string
displayName:
type: string
agentVersion:
type: string
directAddress:
type:
- string
- "null"
relayAddress:
type:
- string
- "null"
exports:
type: array
items:
$ref: "#/components/schemas/StorageExportInput"
NodeHeartbeatRequest:
type: object
required:
- nodeId
- status
- lastSeenAt
properties:
nodeId:
type: string
status:
type: string
enum: [online, offline, degraded]
lastSeenAt:
type: string
MountProfileRequest:
type: object
required:
- userId
- deviceId
- exportId
properties:
userId:
type: string
deviceId:
type: string
exportId:
type: string
CloudProfileRequest:
type: object
required:
- userId
- exportId
- provider
properties:
userId:
type: string
exportId:
type: string
provider:
type: string
enum: [nextcloud]

View file

@ -7,6 +7,7 @@
"types": "./dist/index.d.ts",
"files": [
"dist",
"openapi",
"schemas"
],
"scripts": {
@ -14,4 +15,3 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
}
}

View file

@ -0,0 +1,3 @@
# `@betternas/eslint-config`
Collection of internal eslint configurations.

View file

@ -0,0 +1,32 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import turboPlugin from "eslint-plugin-turbo";
import tseslint from "typescript-eslint";
import onlyWarn from "eslint-plugin-only-warn";
/**
* A shared ESLint configuration for the repository.
*
* @type {import("eslint").Linter.Config[]}
* */
export const config = [
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
{
plugins: {
turbo: turboPlugin,
},
rules: {
"turbo/no-undeclared-env-vars": "warn",
},
},
{
plugins: {
onlyWarn,
},
},
{
ignores: ["dist/**"],
},
];

View file

@ -0,0 +1,57 @@
import js from "@eslint/js";
import { globalIgnores } from "eslint/config";
import eslintConfigPrettier from "eslint-config-prettier";
import tseslint from "typescript-eslint";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReact from "eslint-plugin-react";
import globals from "globals";
import pluginNext from "@next/eslint-plugin-next";
import { config as baseConfig } from "./base.js";
/**
* A custom ESLint configuration for libraries that use Next.js.
*
* @type {import("eslint").Linter.Config[]}
* */
export const nextJsConfig = [
...baseConfig,
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
{
...pluginReact.configs.flat.recommended,
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: {
...globals.serviceworker,
},
},
},
{
plugins: {
"@next/next": pluginNext,
},
rules: {
...pluginNext.configs.recommended.rules,
...pluginNext.configs["core-web-vitals"].rules,
},
},
{
plugins: {
"react-hooks": pluginReactHooks,
},
settings: { react: { version: "detect" } },
rules: {
...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform.
"react/react-in-jsx-scope": "off",
},
},
];

View file

@ -0,0 +1,24 @@
{
"name": "@betternas/eslint-config",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./base": "./base.js",
"./next-js": "./next.js",
"./react-internal": "./react-internal.js"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@next/eslint-plugin-next": "^16.2.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-turbo": "^2.7.1",
"globals": "^16.5.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.50.0"
}
}

View file

@ -0,0 +1,39 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import tseslint from "typescript-eslint";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReact from "eslint-plugin-react";
import globals from "globals";
import { config as baseConfig } from "./base.js";
/**
* A custom ESLint configuration for libraries that use React.
*
* @type {import("eslint").Linter.Config[]} */
export const config = [
...baseConfig,
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
{
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: {
...globals.serviceworker,
...globals.browser,
},
},
},
{
plugins: {
"react-hooks": pluginReactHooks,
},
settings: { react: { version: "detect" } },
rules: {
...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform.
"react/react-in-jsx-scope": "off",
},
},
];

10
packages/sdk-ts/README.md Normal file
View file

@ -0,0 +1,10 @@
# `@betternas/sdk-ts`
Temporary TypeScript-facing SDK surface for the Next.js app.
The source of truth remains:
- [`packages/contracts/openapi`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/openapi)
- [`packages/contracts/src`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/src)
Later this package should become generated code from the OpenAPI contracts.

View file

@ -0,0 +1,3 @@
import { config } from "@betternas/eslint-config/base";
export default config;

View file

@ -0,0 +1,24 @@
{
"name": "@betternas/sdk-ts",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint . --max-warnings 0",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@betternas/contracts": "*"
},
"devDependencies": {
"@betternas/eslint-config": "*",
"@betternas/typescript-config": "*",
"@types/node": "^22.15.3",
"eslint": "^9.39.1",
"typescript": "5.9.2"
}
}

View file

@ -0,0 +1 @@
export * from "@betternas/contracts";

View file

@ -0,0 +1,8 @@
{
"extends": "@betternas/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleDetection": "force",
"moduleResolution": "NodeNext",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
}
}

View file

@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"jsx": "preserve",
"noEmit": true
}
}

View file

@ -0,0 +1,9 @@
{
"name": "@betternas/typescript-config",
"version": "0.0.0",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx"
}
}

View file

@ -0,0 +1,4 @@
import { config } from "@betternas/eslint-config/react-internal";
/** @type {import("eslint").Linter.Config} */
export default config;

27
packages/ui/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "@betternas/ui",
"version": "0.0.0",
"private": true,
"exports": {
"./*": "./src/*.tsx"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint . --max-warnings 0",
"generate:component": "turbo gen react-component",
"check-types": "tsc --noEmit"
},
"devDependencies": {
"@betternas/eslint-config": "*",
"@betternas/typescript-config": "*",
"@types/node": "^22.15.3",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"eslint": "^9.39.1",
"typescript": "5.9.2"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
}

View file

@ -0,0 +1,17 @@
"use client";
import { ReactNode } from "react";
interface ButtonProps {
children: ReactNode;
className?: string;
onClick?: () => void;
}
export const Button = ({ children, className, onClick }: ButtonProps) => {
return (
<button className={className} onClick={onClick} type="button">
{children}
</button>
);
};

22
packages/ui/src/card.tsx Normal file
View file

@ -0,0 +1,22 @@
import { type JSX } from "react";
export function Card({
className,
title,
children,
href,
}: {
className?: string;
title: string;
children: React.ReactNode;
href: string;
}): JSX.Element {
return (
<a className={className} href={href}>
<h2>
{title} <span>-&gt;</span>
</h2>
<p>{children}</p>
</a>
);
}

11
packages/ui/src/code.tsx Normal file
View file

@ -0,0 +1,11 @@
import { type JSX } from "react";
export function Code({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}): JSX.Element {
return <code className={className}>{children}</code>;
}

View file

@ -0,0 +1,9 @@
{
"extends": "@betternas/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"strictNullChecks": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View file

@ -3,7 +3,7 @@
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
compose_file="$repo_root/docker/compose.dev.yml"
compose_file="$repo_root/infra/docker/compose.dev.yml"
args=(down --remove-orphans)

View file

@ -3,8 +3,8 @@
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
compose_file="$repo_root/docker/compose.dev.yml"
app_source_dir="$repo_root/apps/betternascontrolplane"
compose_file="$repo_root/infra/docker/compose.dev.yml"
app_source_dir="$repo_root/apps/nextcloud-app"
nextcloud_occ() {
docker compose -f "$compose_file" exec -T --user www-data --workdir /var/www/html nextcloud php occ "$@"

263
skeleton.md Normal file
View file

@ -0,0 +1,263 @@
# betterNAS Skeleton
This is the root build skeleton for the monorepo.
Its job is simple:
- lock the repo shape
- lock the language per runtime
- lock the first shared contract surface
- give agents a safe place to work in parallel
- keep the list of upstream references we are stealing from
## Repo shape
```text
betterNAS/
├── apps/
│ ├── web/ # Next.js control-plane UI
│ ├── control-plane/ # Go control-plane API
│ ├── node-agent/ # Go NAS runtime + WebDAV surface
│ └── nextcloud-app/ # optional Nextcloud adapter
├── packages/
│ ├── contracts/ # canonical OpenAPI, schemas, TS types
│ ├── sdk-ts/ # TS SDK facade for the web app
│ ├── ui/ # shared React UI
│ ├── eslint-config/ # shared lint config
│ └── typescript-config/ # shared TS config
├── infra/
│ └── docker/ # local runtime stack
├── docs/ # architecture and part docs
├── scripts/ # local helper scripts
├── go.work # Go workspace
├── turbo.json # Turborepo task graph
└── skeleton.md # this file
```
## Runtime and language choices
| Part | Language | Why |
|---|---|---|
| `apps/web` | TypeScript + Next.js | best UI velocity, best admin/control-plane UX |
| `apps/control-plane` | Go | strong concurrency, static binaries, operationally simple |
| `apps/node-agent` | Go | best fit for host runtime, WebDAV service, and future Nix deployment |
| `apps/nextcloud-app` | PHP | native language for the Nextcloud adapter surface |
| `packages/contracts` | OpenAPI + JSON Schema + TypeScript | language-neutral source of truth with practical TS ergonomics |
| `packages/sdk-ts` | TypeScript | ergonomic consumption from the Next.js app |
## Canonical contract rule
The source of truth for shared interfaces is:
1. [`docs/architecture.md`](/home/rathi/Documents/GitHub/betterNAS/docs/architecture.md)
2. [`packages/contracts/openapi/betternas.v1.yaml`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/openapi/betternas.v1.yaml)
3. [`packages/contracts/schemas`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/schemas)
4. [`packages/contracts/src`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/src)
Agents must not invent private shared request or response shapes outside those
locations.
## Parallel lanes
```text
shared write surface
+-------------------------------------------+
| docs/architecture.md |
| packages/contracts/ |
+----------------+--------------------------+
|
+-----------------+-----------------+-----------------+
| | | |
v v v v
NAS node control plane local device cloud layer
lane lane lane lane
```
Allowed ownership:
- NAS node lane
- `apps/node-agent`
- future `infra/nix/node-*`
- control-plane lane
- `apps/control-plane`
- DB and queue integration code later
- local-device lane
- mount docs first
- future helper app
- cloud layer lane
- `apps/nextcloud-app`
- Nextcloud mapping logic
- shared contract lane
- `packages/contracts`
- `packages/sdk-ts`
- `docs/architecture.md`
## The first verification loop
```text
[node-agent]
serves WebDAV export
|
v
[control-plane]
registers node + export
issues mount profile
|
v
[local device]
mounts WebDAV in Finder
|
v
[cloud layer]
optionally exposes same export in Nextcloud
```
If a task does not make one of those steps more real, it is probably too early.
## Upstream references to steal from
### Monorepo and web
- Turborepo `create-turbo`
- https://turborepo.dev/repo/docs/reference/create-turbo
- why: monorepo base scaffold
- Turborepo structuring guide
- https://turborepo.dev/repo/docs/crafting-your-repository/structuring-a-repository
- why: package boundaries and task graph rules
- Next.js backend-for-frontend guide
- https://nextjs.org/docs/app/guides/backend-for-frontend
- why: keep Next.js as UI/BFF, not the system-of-record backend
### Go control plane
- Go routing enhancements
- https://go.dev/blog/routing-enhancements
- why: stdlib-first routing baseline
- `chi`
- https://github.com/go-chi/chi
- why: minimal router if stdlib patterns become too bare
- `pgx`
- https://github.com/jackc/pgx
- why: Postgres-first Go driver
- `sqlc`
- https://github.com/sqlc-dev/sqlc
- why: typed query generation
- `go-redis`
- https://github.com/redis/go-redis
- why: primary Redis client
- `asynq`
- https://github.com/hibiken/asynq
- why: practical Redis-backed job system
- `koanf`
- https://github.com/knadh/koanf
- why: layered config if env-only config becomes too small
- `envconfig`
- https://github.com/kelseyhightower/envconfig
- why: tiny env-only config option
- `log/slog`
- https://pkg.go.dev/log/slog
- why: structured logging without adding a logging framework first
- `oapi-codegen`
- https://github.com/oapi-codegen/oapi-codegen
- why: generate Go and TS surfaces from OpenAPI
### NAS node and WebDAV
- Go WebDAV package
- https://pkg.go.dev/golang.org/x/net/webdav
- why: embeddable WebDAV server implementation
- `hacdias/webdav`
- https://github.com/hacdias/webdav
- why: small standalone WebDAV reference
- NixOS manual
- https://nixos.org/manual/nixos/stable/
- why: declarative host setup and service wiring
- Nixpkgs
- https://github.com/NixOS/nixpkgs
- why: service module and packaging reference
### Local device and mount UX
- Finder `Connect to Server`
- https://support.apple.com/en-lamr/guide/mac-help/mchlp3015/mac
- why: native mount UX baseline
- Finder WebDAV mounting
- https://support.apple.com/is-is/guide/mac-help/mchlp1546/mac
- why: exact v1 mount path
- Keychain data protection
- https://support.apple.com/guide/security/keychain-data-protection-secb0694df1a/web
- why: local credential storage model
- Finder Sync extensions
- https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Finder.html
- why: future helper app / Finder integration reference
- WebDAV RFC 4918
- https://www.rfc-editor.org/rfc/rfc4918
- why: protocol semantics and edge cases
### Cloud and adapter layer
- Nextcloud app template
- https://github.com/nextcloud/app_template
- why: thin adapter app reference
- AppAPI / External Apps
- https://docs.nextcloud.com/server/latest/admin_manual/exapps_management/AppAPIAndExternalApps.html
- why: official external-app integration path
- Nextcloud WebDAV docs
- https://docs.nextcloud.com/server/latest/user_manual/en/files/access_webdav.html
- why: protocol/client behavior reference
- Nextcloud external storage
- https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/external_storage_configuration_gui.html
- why: storage aggregation behavior
- Nextcloud file sharing config
- https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/file_sharing_configuration.html
- why: share semantics reference
## What we steal vs what we own
### Steal
- Turborepo repo shape and task graph
- Next.js web-app conventions
- Go stdlib and proven Go infra libraries
- Go WebDAV implementation
- Finder native WebDAV mount UX
- Nextcloud shell-app and cloud/web primitives
### Own
- the betterNAS domain model
- the control-plane API
- the node registration and export model
- the mount profile model
- the mapping between cloud mode and mount mode
- the repo contract and shared schemas
## The first implementation slices after this scaffold
1. make `apps/node-agent` serve a real configurable WebDAV export
2. make `apps/control-plane` store real node/export records
3. issue real mount profiles from the control plane
4. make `apps/web` let a user pick an export and request a profile
5. keep `apps/nextcloud-app` thin and optional

24
turbo.json Normal file
View file

@ -0,0 +1,24 @@
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {
"dependsOn": ["^lint"]
},
"check-types": {
"dependsOn": ["^check-types"]
},
"test": {
"dependsOn": ["^test"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}