diff --git a/.changeset/create-objectstack-current-major.md b/.changeset/create-objectstack-current-major.md new file mode 100644 index 0000000000..ad8a289619 --- /dev/null +++ b/.changeset/create-objectstack-current-major.md @@ -0,0 +1,5 @@ +--- +"create-objectstack": patch +--- + +Scaffolded projects now install the current framework release instead of a stale major. The bundled `blank` template had `^6.0.0` ranges frozen in while the registry was publishing 14.x, so `npm create objectstack` produced a project eight majors behind the docs — and the template's code no longer compiled against 14.x anyway (`Field.longText` removed, `api.rest` no longer a `defineStack` key, `sharingModel` now required by the ADR-0090 security gate). The template is updated to the current API, and the scaffolder now rewrites every `@objectstack/*` range in the generated `package.json` to `^` (all packages version in lockstep), so generated projects track the release even if the committed template drifts again. A consistency test ratchets the template's major and the README's template table against the registry. The template README also documents the seeded dev-admin sign-in that data-API curls need. diff --git a/.changeset/validate-security-posture-parity.md b/.changeset/validate-security-posture-parity.md new file mode 100644 index 0000000000..6284e44a3a --- /dev/null +++ b/.changeset/validate-security-posture-parity.md @@ -0,0 +1,5 @@ +--- +"@objectstack/cli": patch +--- + +`os validate` now runs the ADR-0090 D7 security posture check, restoring its documented contract of being the artifact-free run of the same gates as `os compile`/`os build`. Previously a stack could pass `validate` and then fail the build — e.g. a custom object with no explicit `sharingModel` (OWD), which the posture linter rejects at compile. Error findings gate validation; advisory findings print as warnings (and join the `--json` warnings array). diff --git a/content/docs/deployment/backup-restore.mdx b/content/docs/deployment/backup-restore.mdx new file mode 100644 index 0000000000..8f0097bc62 --- /dev/null +++ b/content/docs/deployment/backup-restore.mdx @@ -0,0 +1,126 @@ +--- +title: Backup & Restore +description: What state an ObjectStack deployment actually holds, how to back up each piece per database driver, and the restore drill to rehearse before you need it. +--- + +# Backup & Restore + +The [go-live checklist](/docs/deployment/production-readiness#go-live-checklist) +requires a documented, tested backup/restore drill. This page is that drill: +what to back up, how, and how to prove the restore works. + +## What state a deployment holds + +An ObjectStack app is deliberately easy to back up because almost everything +lives in one of four places: + +| State | Where it lives | Backup strategy | +|:---|:---|:---| +| **Business data** (records, users, orgs, runtime-authored metadata, encrypted `sys_secret` values) | The database behind `OS_DATABASE_URL` | Database-native backup — see below | +| **Authored metadata** (objects, views, flows, …) | Your project source + the compiled artifact | Git. The artifact is rebuildable with `os build`; no runtime backup needed | +| **Secrets** (`OS_SECRET_KEY`, `OS_AUTH_SECRET`, DB credentials) | Your secret manager | Escrow the values themselves — see the warning below | +| **The ObjectStack home dir** (`~/.objectstack` or `/.objectstack`: default SQLite DB under `data/`, uploaded files under `data/uploads/`, dev-minted keys) | Local filesystem | Include in file backups on single-host deployments; empty on stateless containers that set `OS_DATABASE_URL` + `OS_SECRET_KEY` explicitly | + + +**A database backup without `OS_SECRET_KEY` is an incomplete backup.** Every +`sys_secret` value (encrypted settings, `secret` fields, datasource +credentials) is encrypted under that one key. Restoring the database on a new +host without the original key leaves those values permanently undecryptable. +Escrow the key in your secret manager the day you generate it, and treat +key + database as one backup unit. + + +## Backing up the database + +Use the native tooling of whatever `OS_DATABASE_URL` points at — ObjectStack +adds no extra backup surface on top of the database. + +### PostgreSQL + +```bash +# Logical backup (small/medium databases; restore with pg_restore) +pg_dump --format=custom "$OS_DATABASE_URL" > backup-$(date +%F).dump + +# Larger deployments: continuous archiving / point-in-time recovery +# (WAL archiving, or your provider's managed snapshots) +``` + +### SQLite (single host) + +The database is a single file — but never copy it while the server is +writing. Use SQLite's online backup instead: + +```bash +sqlite3 /srv/data/app.db ".backup '/backups/app-$(date +%F).db'" +``` + +The default location when `OS_DATABASE_URL` is unset is +`/data/objectstack.db` under the ObjectStack home directory. + +### Turso / libSQL and managed databases + +Use the provider's snapshot, branching, or point-in-time-restore facilities. +Nothing ObjectStack-specific applies. + +## Backing up uploaded files + +The local storage adapter keeps uploads under `OS_STORAGE_ROOT` (default +`./.objectstack/data/uploads`). On single-host deployments, include that +directory in the same schedule as the database so records and their +attachments restore to the same point in time. Deployments using an external +storage service (S3-compatible, etc.) inherit that service's durability and +versioning instead. + +## The restore drill + +Rehearse this on a scratch host **before** go-live, and again after any +storage change. A backup you haven't restored is a hope, not a backup. + + + +### Provision a clean host + +Fresh container or VM. Install Node 18+ and `@objectstack/cli`. + +### Restore the database + +```bash +pg_restore --clean --if-exists -d "$OS_DATABASE_URL" backup-2026-07-14.dump +# or copy the SQLite file / restore the provider snapshot +``` + +### Provide the original secrets + +Set `OS_SECRET_KEY` and `OS_AUTH_SECRET` to the **escrowed originals** — not +freshly generated values. Restore `OS_STORAGE_ROOT` contents if you use local +file storage. + +### Boot from the artifact and verify + +```bash +OS_ARTIFACT_PATH=./objectstack.json OS_PORT=8080 os start +curl -fsS http://localhost:8080/api/v1/health +curl -fsS http://localhost:8080/api/v1/ready +``` + +Then prove the restore end-to-end: sign in with a real account, open a record +that existed before the backup, and read a value that lives in an encrypted +setting (which exercises `OS_SECRET_KEY`). + + + +## Retention is not backup + +The platform's `lifecycle` declarations bound telemetry data (activity 14d, +job runs 30d, notifications 90d, audit 90d-hot) — deleted-by-policy data is +gone from backups taken afterwards. If compliance needs longer, extend +`lifecycle.retention_overrides` **before** go-live, and register an `archive` +datasource if audit data must move to cold storage instead of being retained +hot. See [Production Readiness](/docs/deployment/production-readiness). + +## Related + +- [Self-Hosted Deployment](/docs/deployment/self-hosting) +- [Production Readiness](/docs/deployment/production-readiness) +- [Environment Variables](/docs/deployment/environment-variables) +- [Drivers](/docs/data-modeling/drivers) diff --git a/content/docs/deployment/meta.json b/content/docs/deployment/meta.json index c1d71d1422..bc2345ac15 100644 --- a/content/docs/deployment/meta.json +++ b/content/docs/deployment/meta.json @@ -4,6 +4,7 @@ "pages": [ "index", "self-hosting", + "backup-restore", "vercel", "production-readiness", "publish-and-preview", diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index e34f41f82f..c1e83f60fd 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -88,7 +88,10 @@ back by restoring the previous artifact. ## Option 2 — Docker The artifact model maps cleanly onto containers: the image contains Node, the -CLI, and one JSON file. +CLI, and one JSON file. The Dockerfile below (plus the compose stack in the +next section and a `.dockerignore`) also ships ready-to-copy in +[`examples/docker`](https://github.com/objectstack-ai/framework/tree/main/examples/docker) +— drop the files into your scaffolded project. ```dockerfile title="Dockerfile" # ── Build stage: compile TypeScript metadata to the artifact ───────── @@ -195,9 +198,54 @@ Every runtime exposes two probe endpoints — wire them into Docker | `GET /api/v1/health` | Process is up and serving HTTP | Liveness probe | | `GET /api/v1/ready` | Kernel booted, ready for traffic | Readiness probe | -On Kubernetes, the same image works unchanged: mount the secrets as env vars, -point `readinessProbe` at `/api/v1/ready`, and scale — but read the multi-node -note below first. +### Kubernetes + +The same image works unchanged. A minimal reference Deployment — secrets from +a `Secret`, probes on the two endpoints above: + +```yaml title="deployment.yaml" +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app +spec: + replicas: 1 # >1 requires OS_CLUSTER_DRIVER — see below + selector: + matchLabels: { app: my-app } + template: + metadata: + labels: { app: my-app } + spec: + containers: + - name: app + image: registry.example.com/my-app:latest + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: my-app-secrets # OS_DATABASE_URL, OS_AUTH_SECRET, OS_SECRET_KEY + livenessProbe: + httpGet: { path: /api/v1/health, port: 8080 } + periodSeconds: 30 + readinessProbe: + httpGet: { path: /api/v1/ready, port: 8080 } + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: my-app +spec: + selector: { app: my-app } + ports: + - port: 80 + targetPort: 8080 +``` + +`/api/v1/ready` returns 503 while the kernel is booting *and* during graceful +shutdown, so rolling restarts drain cleanly. Before setting `replicas > 1`, +read the multi-node note below. ## Reverse proxy & TLS @@ -247,6 +295,7 @@ Disable with `OS_MCP_SERVER_ENABLED=false`. See ## Related +- [Backup & Restore](/docs/deployment/backup-restore) — what to back up, and the restore drill - [Deployment Modes](/docs/deployment) — the map of local / standalone / Cloud - [`os start` reference](/docs/getting-started/cli#os-start) — every flag and env var - [Environment Variables](/docs/deployment/environment-variables) — the full catalog diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 1af7dcac0a..5c2bf5368f 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -114,7 +114,7 @@ export const Ticket = ObjectSchema.create({ fields: { subject: Field.text({ label: 'Subject', required: true, searchable: true, maxLength: 200 }), - description: Field.longText({ label: 'Description' }), + description: Field.textarea({ label: 'Description' }), priority: Field.select({ label: 'Priority', @@ -139,6 +139,7 @@ export const Ticket = ObjectSchema.create({ }), }, + sharingModel: 'private', // org-wide default (OWD) — required by the security gate enable: { apiEnabled: true, searchable: true }, }); ``` diff --git a/content/docs/getting-started/cli.mdx b/content/docs/getting-started/cli.mdx index cdbae50955..0731d1c7de 100644 --- a/content/docs/getting-started/cli.mdx +++ b/content/docs/getting-started/cli.mdx @@ -22,11 +22,11 @@ The CLI is available as `objectstack` or the shorter alias `os`. Installed as a ### Create a project ```bash -npx @objectstack/cli init my-app +npm create objectstack@latest my-app cd my-app ``` -This scaffolds a working project with `objectstack.config.ts`, a sample object, and all dependencies installed. +This scaffolds a working project with `objectstack.config.ts`, a sample object, and all dependencies installed — plus the AI skills bundle and an `AGENTS.md` for coding agents. (`os init` is the CLI's own scaffolder for plugin skeletons and bare configs — see [below](#os-init).) ### Add more metadata @@ -80,6 +80,11 @@ predicate/schema/binding mistakes that fail silently at runtime), and `os dev -- Scaffolds a new ObjectStack project with configuration, TypeScript setup, and initial metadata files. +> **Which scaffolder?** For a new app, prefer **`npm create objectstack@latest`** — it +> also derives your namespace, pins the framework packages to the current release, and +> installs the AI skills bundle + `AGENTS.md`. Reach for `os init` when you want a +> **plugin** skeleton or a **bare config** in an existing directory. + ```bash os init my-app # Create with default "app" template os init my-plugin -t plugin # Create a plugin project diff --git a/content/docs/getting-started/quick-start.mdx b/content/docs/getting-started/quick-start.mdx index 71ba54326c..84324a545f 100644 --- a/content/docs/getting-started/quick-start.mdx +++ b/content/docs/getting-started/quick-start.mdx @@ -61,7 +61,7 @@ export const Ticket = ObjectSchema.create({ fields: { subject: Field.text({ label: 'Subject', required: true, searchable: true, maxLength: 200 }), - description: Field.longText({ label: 'Description' }), + description: Field.textarea({ label: 'Description' }), status: Field.select({ label: 'Status', @@ -75,6 +75,7 @@ export const Ticket = ObjectSchema.create({ }), }, + sharingModel: 'private', // org-wide default (OWD) — required by the security gate enable: { apiEnabled: true, searchable: true }, }); ``` @@ -83,11 +84,14 @@ export const Ticket = ObjectSchema.create({ - **Names** are `snake_case` and namespace-prefixed (`support_desk_ticket`) — the scaffolder derives the prefix from your project name. -- **Field types** are `Field.*` builders (`text`, `longText`, `select`, `date`, +- **Field types** are `Field.*` builders (`text`, `textarea`, `select`, `date`, `lookup`, …), not raw strings. A `select`'s `options` carry the exact labels, values, and colors you'll see in the UI — verify these match your intent. - **`required`, `default`, `searchable`** encode behavior the UI and API inherit automatically. +- **`sharingModel`** is the org-wide default for records the user doesn't own — + an explicit, authored security decision (`private` unless you have a reason + otherwise). The validation gate rejects custom objects that omit it. This one definition powers the REST API, the Console UI, **and the MCP tools diff --git a/content/docs/getting-started/validating-metadata.mdx b/content/docs/getting-started/validating-metadata.mdx index 2dfe28513f..6c87b369d8 100644 --- a/content/docs/getting-started/validating-metadata.mdx +++ b/content/docs/getting-started/validating-metadata.mdx @@ -57,6 +57,7 @@ dangling one. | Protocol schema (Zod) | ✓ | ✓ | | CEL / predicate validation | ✓ | ✓ | | Widget-binding integrity | ✓ | ✓ | +| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) | ✓ | ✓ | | Emits `dist/objectstack.json` | — | ✓ | So `os validate` is the fast inner-loop check (no artifact); `os build` is what diff --git a/content/docs/getting-started/your-first-project.mdx b/content/docs/getting-started/your-first-project.mdx index 10d8afe553..9acac917e2 100644 --- a/content/docs/getting-started/your-first-project.mdx +++ b/content/docs/getting-started/your-first-project.mdx @@ -112,12 +112,12 @@ export default defineStack({ name: 'My App', }, objects: Object.values(objects), - api: { - rest: { enabled: true, basePath: '/api' }, - }, }); ``` +The REST API needs no configuration — it is on by default for every object +with `apiEnabled`. + **`src/objects/note.object.ts`** is a complete data model — schema, validation, API surface, and searchability in one typed definition: @@ -132,9 +132,10 @@ export const Note = ObjectSchema.create({ fields: { title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 200 }), - body: Field.longText({ label: 'Body' }), + body: Field.textarea({ label: 'Body' }), }, + sharingModel: 'private', // org-wide default — an explicit, authored decision enable: { apiEnabled: true, searchable: true }, }); ``` @@ -166,17 +167,24 @@ npx os dev --ui # then open http://localhost:3000/_console/ ## 4. Call your API -The REST API is derived from the object metadata — you wrote no routes. With -the dev server running: +The REST API is derived from the object metadata — you wrote no routes. Data +endpoints run under the same permission model as the UI, so first sign in as +the dev admin the server seeds on an empty database +(`admin@objectos.ai` / `admin123`): ```bash +# Sign in once — stores the session cookie +curl -c cookies.txt -X POST http://localhost:3000/api/v1/auth/sign-in/email \ + -H "Content-Type: application/json" \ + -d '{"email": "admin@objectos.ai", "password": "admin123"}' + # Create a record -curl -X POST http://localhost:3000/api/v1/data/my_app_note \ +curl -b cookies.txt -X POST http://localhost:3000/api/v1/data/my_app_note \ -H "Content-Type: application/json" \ -d '{"title": "Hello ObjectStack", "body": "Created via the generated API"}' # Query records (filtering, sorting, pagination built in) -curl "http://localhost:3000/api/v1/data/my_app_note?select=title&sort=-created_at&top=10" +curl -b cookies.txt "http://localhost:3000/api/v1/data/my_app_note?select=title&sort=-created_at&top=10" ``` See the [Data API](/docs/api/data-api) for the full CRUD, batch, and analytics @@ -190,7 +198,7 @@ Add a field and a second object by hand — the same edit an agent would make. ```typescript title="src/objects/note.object.ts" fields: { title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 200 }), - body: Field.longText({ label: 'Body' }), + body: Field.textarea({ label: 'Body' }), status: Field.select({ label: 'Status', options: [ diff --git a/examples/docker/.dockerignore b/examples/docker/.dockerignore new file mode 100644 index 0000000000..0880b59cb0 --- /dev/null +++ b/examples/docker/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +*.log +.env +cookies.txt diff --git a/examples/docker/Dockerfile b/examples/docker/Dockerfile new file mode 100644 index 0000000000..8dc6d44ca8 --- /dev/null +++ b/examples/docker/Dockerfile @@ -0,0 +1,39 @@ +# Reference Dockerfile for a standalone ObjectStack app. +# +# Copy this file (plus .dockerignore and docker-compose.yml) into the root of +# a project scaffolded with `npm create objectstack@latest`, then: +# +# docker build -t my-app . +# docker run -p 8080:8080 \ +# -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ +# -e OS_AUTH_SECRET -e OS_SECRET_KEY \ +# my-app +# +# Docs: https://docs.objectstack.ai/docs/deployment/self-hosting + +# ── Build stage: compile TypeScript metadata to the artifact ───────── +FROM node:22-slim AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npx os build # → dist/objectstack.json + +# ── Runtime stage: CLI + artifact only ─────────────────────────────── +FROM node:22-slim +WORKDIR /srv/app +RUN npm install -g @objectstack/cli +COPY --from=build /app/dist/objectstack.json ./objectstack.json + +ENV NODE_ENV=production \ + OS_ARTIFACT_PATH=/srv/app/objectstack.json \ + OS_PORT=8080 +EXPOSE 8080 + +# Liveness: /api/v1/health. For orchestrated readiness use /api/v1/ready. +HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \ + CMD node -e "fetch('http://localhost:8080/api/v1/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + +# OS_DATABASE_URL, OS_AUTH_SECRET, and OS_SECRET_KEY are injected at runtime — +# never bake them into the image. +CMD ["os", "start"] diff --git a/examples/docker/README.md b/examples/docker/README.md new file mode 100644 index 0000000000..465e85d371 --- /dev/null +++ b/examples/docker/README.md @@ -0,0 +1,36 @@ +# Docker Reference for Standalone ObjectStack Apps + +Reference container packaging for a project scaffolded with +`npm create objectstack@latest`. These files are **meant to be copied into +your app**, not built from this directory — the example apps in this repo use +`workspace:*` dependencies and are not standalone-buildable. + +```bash +npm create objectstack@latest my-app +cd my-app +cp /examples/docker/{Dockerfile,docker-compose.yml,.dockerignore} . + +# Image only +docker build -t my-app . + +# Or the full app + Postgres stack +cat > .env <); + const securityErrors = securityFindings.filter((f) => f.severity === 'error'); + const securityAdvisories = securityFindings.filter((f) => f.severity !== 'error'); + if (securityErrors.length > 0) { + if (flags.json) { + console.log(JSON.stringify({ + valid: false, + errors: securityErrors, + duration: timer.elapsed(), + }, null, 2)); + this.exit(1); + } + console.log(''); + printError(`Security posture check failed (${securityErrors.length} issue${securityErrors.length > 1 ? 's' : ''})`); + for (const f of securityErrors.slice(0, 50)) { + console.log(` • ${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); + } + this.exit(1); + } + if (!flags.json) { + for (const f of securityAdvisories.slice(0, 50)) { + console.log(chalk.yellow(` ⚠ ${f.where}: ${f.message}`)); + console.log(chalk.dim(` ${f.hint}`)); + } + } + // 4. Collect and display stats const stats = collectMetadataStats(config); @@ -347,7 +382,7 @@ export default class Validate extends Command { valid: true, manifest: config.manifest, stats, - warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings], + warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...securityAdvisories], conversions: conversionNotices, duration: timer.elapsed(), }, null, 2)); diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 1357c22f92..c1c37577de 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,13 +1,14 @@ { "name": "create-objectstack", "version": "14.7.0", - "description": "Create a new ObjectStack project — npx create-objectstack", + "description": "Create a new ObjectStack project \u2014 npx create-objectstack", "bin": { "create-objectstack": "./bin/create-objectstack.js" }, "scripts": { "build": "tsup", - "dev": "tsup --watch" + "dev": "tsup --watch", + "test": "vitest run" }, "keywords": [ "objectstack", @@ -27,7 +28,8 @@ "@types/node": "^26.1.1", "@types/tar": "^7.0.87", "tsup": "^8.5.1", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" }, "repository": { "type": "git", diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index e94a90c6e1..53989302bf 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -41,6 +41,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; // eslint-disable-next-line import/no-unresolved import * as tar from 'tar'; +import { syncObjectStackDeps } from './pkg-utils.js'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const BUNDLED_TEMPLATES_DIR = path.resolve(__dirname, 'templates'); @@ -276,12 +278,19 @@ function rewriteProjectIdentity( } } - // package.json — set .name + // package.json — set .name and pin @objectstack/* deps to this scaffolder's + // own release line. All @objectstack packages (including create-objectstack) + // version in lockstep, so `^` always resolves and always matches + // the framework the docs describe. Without this, a template whose literal + // ranges have gone stale scaffolds a project several majors behind the + // published framework (the `^6.0.0`-era templates installed 6.x while the + // registry was at 14.x). const pkgPath = path.join(targetDir, 'package.json'); if (fs.existsSync(pkgPath)) { try { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); pkg.name = projectName; + syncObjectStackDeps(pkg, readCliVersion()); fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); } catch { // leave the file alone if it isn't valid JSON diff --git a/packages/create-objectstack/src/pkg-utils.ts b/packages/create-objectstack/src/pkg-utils.ts new file mode 100644 index 0000000000..f6f1959ebe --- /dev/null +++ b/packages/create-objectstack/src/pkg-utils.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Pure helpers for rewriting a scaffolded project's package.json. Kept out of +// index.ts because that module calls `program.parse()` on import — anything a +// test needs must be importable without running the CLI. + +/** + * Rewrite every `@objectstack/*` range in dependencies/devDependencies to + * `^`. All @objectstack packages version in lockstep, so the + * scaffolder's own version always resolves and always matches the framework + * the docs describe. No-op when the version is unknown (`0.0.0` fallback) so + * a broken version read never corrupts the template's committed baseline. + */ +export function syncObjectStackDeps( + pkg: { dependencies?: Record; devDependencies?: Record }, + version: string, +): void { + if (!/^\d+\.\d+\.\d+/.test(version) || version.startsWith('0.')) return; + for (const deps of [pkg.dependencies, pkg.devDependencies]) { + if (!deps) continue; + for (const dep of Object.keys(deps)) { + if (dep.startsWith('@objectstack/')) { + deps[dep] = `^${version}`; + } + } + } +} diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts new file mode 100644 index 0000000000..5be0bb520a --- /dev/null +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Drift ratchets for the scaffolder's user-facing surfaces. Both of these +// rotted silently once before (#2899 follow-up): the bundled template pinned +// `^6.0.0` while the registry was publishing 14.x, and the README advertised +// a template set (`minimal-api`/`full-stack`/`plugin`) that never shipped. + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { syncObjectStackDeps } from './pkg-utils.js'; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ownPkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')); +const ownMajor = Number(ownPkg.version.split('.')[0]); + +// The TEMPLATES registry, read from src/index.ts as text — importing the +// module would run the CLI (it calls program.parse() on import). Parse only +// the lines between the TEMPLATES declaration and its closing brace. +const REGISTRY_SOURCE = fs.readFileSync(path.join(pkgRoot, 'src', 'index.ts'), 'utf8'); +const registryBlock = + /const TEMPLATES: Record = \{([\s\S]*?)\n\};/.exec( + REGISTRY_SOURCE, + )?.[1] ?? ''; +const registryTemplates = [...registryBlock.matchAll(/^ ([a-z][a-z0-9_]*): \{$/gm)].map( + (m) => m[1], +); + +describe('blank template package.json', () => { + const templatePkg = JSON.parse( + fs.readFileSync( + path.join(pkgRoot, 'src', 'templates', 'blank', 'package.json'), + 'utf8', + ), + ); + + it('pins every @objectstack/* dep to the current major', () => { + const allDeps = { ...templatePkg.dependencies, ...templatePkg.devDependencies }; + const stackDeps = Object.entries(allDeps).filter(([name]) => + name.startsWith('@objectstack/'), + ); + expect(stackDeps.length).toBeGreaterThan(0); + for (const [name, range] of stackDeps) { + const match = /^\^(\d+)\./.exec(String(range)); + expect(match, `${name} range "${range}" must be ^.x`).not.toBeNull(); + expect( + Number(match![1]), + `${name} pins ^${match![1]}.x but create-objectstack is v${ownMajor} — ` + + 'bump the template with the release (scaffold-time sync only fixes ' + + 'generated projects, not this committed baseline)', + ).toBe(ownMajor); + } + }); +}); + +describe('README template table', () => { + it('lists exactly the templates in the TEMPLATES registry', () => { + const readme = fs.readFileSync(path.join(pkgRoot, 'README.md'), 'utf8'); + // Table rows under "## Templates": | `name` ... | source | description | + const section = readme.split(/^## Templates$/m)[1]?.split(/^## /m)[0] ?? ''; + const documented = [...section.matchAll(/^\| `([a-z][a-z0-9_-]*)`/gm)].map( + (m) => m[1], + ); + expect(documented.sort()).toEqual([...registryTemplates].sort()); + }); +}); + +describe('syncObjectStackDeps', () => { + it('rewrites @objectstack/* ranges in deps and devDeps', () => { + const pkg = { + dependencies: { '@objectstack/spec': '^6.0.0', chalk: '^5.0.0' }, + devDependencies: { '@objectstack/cli': '^6.0.0', typescript: '^6.0.0' }, + }; + syncObjectStackDeps(pkg, '14.7.0'); + expect(pkg.dependencies['@objectstack/spec']).toBe('^14.7.0'); + expect(pkg.devDependencies['@objectstack/cli']).toBe('^14.7.0'); + expect(pkg.dependencies.chalk).toBe('^5.0.0'); + expect(pkg.devDependencies.typescript).toBe('^6.0.0'); + }); + + it('is a no-op on the 0.0.0 fallback version', () => { + const pkg = { dependencies: { '@objectstack/spec': '^6.0.0' } }; + syncObjectStackDeps(pkg, '0.0.0'); + expect(pkg.dependencies['@objectstack/spec']).toBe('^6.0.0'); + }); +}); diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md index 919c08fa33..1b0c5eaeeb 100644 --- a/packages/create-objectstack/src/templates/blank/README.md +++ b/packages/create-objectstack/src/templates/blank/README.md @@ -9,7 +9,17 @@ pnpm install pnpm dev ``` -The REST API is served at `http://localhost:3000/api`. +The REST API is served at `http://localhost:3000/api/v1`. Data endpoints +require a session — the dev server seeds a login-ready admin +(`admin@objectos.ai` / `admin123`) on an empty database: + +```bash +curl -c cookies.txt -X POST http://localhost:3000/api/v1/auth/sign-in/email \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@objectos.ai","password":"admin123"}' + +curl -b cookies.txt "http://localhost:3000/api/v1/data/" +``` ## Layout diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts index d4fd0da76f..48abaddd16 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts +++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts @@ -11,10 +11,4 @@ export default defineStack({ description: 'Minimal ObjectStack environment — a clean slate for building.', }, objects: Object.values(objects), - api: { - rest: { - enabled: true, - basePath: '/api', - }, - }, }); diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index 13db4acb6b..07be83fd06 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -11,13 +11,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@objectstack/spec": "^6.0.0", - "@objectstack/runtime": "^6.0.0", - "@objectstack/driver-memory": "^6.0.0", - "@objectstack/plugin-hono-server": "^6.0.0" + "@objectstack/spec": "^14.0.0", + "@objectstack/runtime": "^14.0.0", + "@objectstack/driver-memory": "^14.0.0", + "@objectstack/plugin-hono-server": "^14.0.0" }, "devDependencies": { - "@objectstack/cli": "^6.0.0", - "typescript": "^5.3.0" + "@objectstack/cli": "^14.0.0", + "typescript": "^6.0.0" } } diff --git a/packages/create-objectstack/src/templates/blank/src/objects/note.object.ts b/packages/create-objectstack/src/templates/blank/src/objects/note.object.ts index c018dc9669..9197ac8470 100644 --- a/packages/create-objectstack/src/templates/blank/src/objects/note.object.ts +++ b/packages/create-objectstack/src/templates/blank/src/objects/note.object.ts @@ -16,11 +16,15 @@ export const Note = ObjectSchema.create({ searchable: true, maxLength: 200, }), - body: Field.longText({ + body: Field.textarea({ label: 'Body', }), }, + // Org-wide default (OWD): who can see records they don't own. The security + // posture gate (ADR-0090) requires an explicit, authored decision here. + sharingModel: 'private', + enable: { apiEnabled: true, searchable: true, diff --git a/packages/create-objectstack/vitest.config.ts b/packages/create-objectstack/vitest.config.ts new file mode 100644 index 0000000000..bd4f0bf346 --- /dev/null +++ b/packages/create-objectstack/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts', 'src/**/*.test.ts'], + environment: 'node', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fa464c69d..366f61d18c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -733,6 +733,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(msw@2.14.6(@types/node@26.1.1)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) packages/dogfood: dependencies: