This commit is contained in:
Hari 2026-03-31 23:50:51 -04:00 committed by GitHub
parent 4f174ec3a8
commit b68151035a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 6263 additions and 545 deletions

3
.gitignore vendored
View file

@ -3,3 +3,6 @@ dist/
.DS_Store .DS_Store
npm-debug.log* npm-debug.log*
.codex/ .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 control plane is the system of record.
- the NAS node serves bytes directly whenever possible. - the NAS node serves bytes directly whenever possible.
- Nextcloud is an optional cloud/web adapter, not the product center. - 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 | | Part | Steal first | Write ourselves |
|---|---|---| |---|---|---|
| NAS node | NixOS/Nix module patterns, existing WebDAV servers | node agent, export model, node registration flow | | 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 | | 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 | | 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 ## 2. Control plane
Start from: Start from:
- the current `exapps/control-plane` scaffold - Go
- TypeScript - standard library routing first
- Postgres - Postgres via `pgx` and `sqlc`
- Redis - Redis via `go-redis`
- OpenAPI-driven contracts
- standalone API mindset - standalone API mindset
Do not start by writing: Do not start by writing:
@ -151,7 +152,7 @@ This is high leverage, but should not block Phase A.
- Nix module patterns - Nix module patterns
### Control plane ### Control plane
- current TypeScript repo scaffold - Go API service scaffold
- Postgres - Postgres
- Redis - Redis
@ -193,7 +194,7 @@ This is high leverage, but should not block Phase A.
| Part | First scaffold | | Part | First scaffold |
|---|---| |---|---|
| NAS node | Nix flake/module + WebDAV server service config | | 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 | | Local device | documented Finder mount flow, then lightweight helper app |
| Cloud/web layer | current Nextcloud scaffold and shell 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/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) - [`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 contract surface we need first
The first shared contract set should cover only the seams that let all four 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 ## 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 ### Backend and infra references
- Fastify - Go routing enhancements
- repo: https://github.com/fastify/fastify - docs: https://go.dev/blog/routing-enhancements
- why: likely good fit for a TypeScript API backend - 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 - PostgreSQL
- docs: https://www.postgresql.org/docs/ - docs: https://www.postgresql.org/docs/
- why: source of truth for product metadata - 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 - Redis
- docs: https://redis.io/docs/latest/ - docs: https://redis.io/docs/latest/
- why: cache, jobs, ephemeral coordination - 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` - `sshpiper`
- repo: https://github.com/tg123/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/ - docs: https://developer.apple.com/documentation/
- why: Keychain, launch agents, desktop helpers, future native integration - 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 ## Cloud / web layer
### Nextcloud server and app references ### 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: control-plane:
build: build:
context: .. context: ../..
dockerfile: exapps/control-plane/Dockerfile dockerfile: apps/control-plane/Dockerfile
environment: environment:
PORT: 3000 PORT: 3000
BETTERNAS_VERSION: local-dev 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", "name": "betternas",
"private": true, "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", "packageManager": "npm@10.9.7",
"workspaces": [ "workspaces": [
"packages/*", "apps/*",
"exapps/*" "packages/*"
], ]
"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"
}
} }

View file

@ -12,6 +12,7 @@ Use it to keep the four product parts aligned:
## What belongs here ## What belongs here
- OpenAPI source documents
- shared TypeScript types - shared TypeScript types
- route constants - route constants
- JSON schemas for payloads we want to validate outside TypeScript - 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 - current runtime scaffold for health and version
- [`src/foundation.ts`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/src/foundation.ts) - [`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 - 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) - [`schemas/`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/schemas)
- JSON schema mirrors for the first shared entities - 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", "types": "./dist/index.d.ts",
"files": [ "files": [
"dist", "dist",
"openapi",
"schemas" "schemas"
], ],
"scripts": { "scripts": {
@ -14,4 +15,3 @@
"typecheck": "tsc --noEmit -p tsconfig.json" "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 set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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) args=(down --remove-orphans)

View file

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