diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 55d1954..19ba55f 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -137,6 +137,7 @@ jobs: test -f src/observability/alert-policy.mjs test -f src/observability/observability.test.mjs test -f doc/lifecycle-backup-dr.md + test -f doc/local-proof-scope.md test -f src/ops/acme/index.mjs test -f src/ops/acme/index.test.mjs test -f src/ops/abuse/index.mjs @@ -150,6 +151,10 @@ jobs: test -f src/release/index.mjs test -f src/release/release-evidence.mjs test -f src/release/release-evidence.test.mjs + test -f src/release/local-proof-scope.mjs + test -f src/release/local-proof-scope.test.mjs + test -f release/local-proof-scope.json + test -f scripts/lp0-scope-audit.mjs test -f doc/upgrade-and-migration.md grep -Fq 'profiles: ["local"]' compose.yaml grep -Fq 'profiles: ["test"]' compose.yaml @@ -169,6 +174,7 @@ jobs: scripts/m0-smoke.ps1 \ scripts/m1-fixture-smoke.ps1 \ scripts/container-patch.sh \ + scripts/lp0-scope-audit.mjs \ scripts/m10-release-audit.mjs \ .gitignore \ .github/workflows/commit-tests.yml \ @@ -280,6 +286,7 @@ jobs: node -e "const s=require('./config/schema.v1.json'); if (!s.properties.buildDigest || !s.properties.patching) process.exit(1)" test -f package-lock.json node -e "const e=require('./release/v1-release-evidence.template.json'); if (e.spdxLicenseIdentifier !== 'MIT' || e.author !== 'Sythos (https://www.sythos.net)' || e.product !== 'Gulo Gulo') process.exit(1)" + node -e "const s=require('./release/local-proof-scope.json'); if (s.spdxLicenseIdentifier !== 'MIT' || s.author !== 'Sythos (https://www.sythos.net)' || s.proofType !== 'local' || s.networkPolicy !== 'offline_runtime' || s.syntheticDataOnly !== true || s.publicDnsRequired !== false || s.publicAcmeEnabled !== false || s.externalPhaseDeferred !== true) process.exit(1)" - name: Set up Node.js when package metadata exists if: hashFiles('package.json') != '' diff --git a/README.md b/README.md index e0a6db7..33ea91b 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ gulogulo/ │ ├── dav-and-discovery.md │ ├── identity-and-postgres.md │ ├── lifecycle-backup-dr.md +│ ├── local-proof-scope.md │ ├── mail-core.md │ ├── rbac-admin-mfa.md │ ├── release-readiness.md @@ -135,10 +136,12 @@ gulogulo/ ├── scripts/ │ ├── m0-smoke.ps1 │ ├── m1-fixture-smoke.ps1 +│ ├── lp0-scope-audit.mjs │ ├── m10-release-audit.mjs │ ├── container-patch.sh │ └── runtime, fixture, and patch utilities ├── release/ +│ ├── local-proof-scope.json │ └── v1-release-evidence.template.json ├── src/ │ ├── admin/ @@ -152,6 +155,8 @@ gulogulo/ │ ├── observability/ │ ├── release/ │ │ ├── index.mjs +│ │ ├── local-proof-scope.mjs +│ │ ├── local-proof-scope.test.mjs │ │ ├── release-evidence.mjs │ │ └── release-evidence.test.mjs │ ├── ops/ diff --git a/doc/README.md b/doc/README.md index 2ad07d8..1568a16 100644 --- a/doc/README.md +++ b/doc/README.md @@ -54,6 +54,9 @@ normal workflow. - [Release readiness](release-readiness.md) — the Section 30 evidence object, hardening review matrix, sanitized provider API/MCP read surface, and the honest boundary between a usable contract preview and a production service. +- [LP0 local proof scope](local-proof-scope.md) — the reserved local names, + synthetic-data rule, offline runtime boundary, disposable service inventory, + architecture targets, and the explicit deferral of the external phase. The project is still intentionally small. The documents describe real behavior only: DAV, administration, lifecycle, backup, observability, ACME, abuse, and diff --git a/doc/local-proof-scope.md b/doc/local-proof-scope.md new file mode 100644 index 0000000..debc9bf --- /dev/null +++ b/doc/local-proof-scope.md @@ -0,0 +1,126 @@ +# LP0 local proof scope + + + +LP0 is the deliberately boring freeze before the first local deployment +rehearsal. It says exactly what the local proof is allowed to prove, what it +must never accidentally touch, and which services LP1 and the later local +milestones will add. It is a contract, not a claim that the current scaffold is +already a complete mail provider. + +## The short version + +The local proof is an isolated, synthetic-data-only deployment of Gulo Gulo. +It uses reserved names, an internal certificate authority, and an offline +runtime network. It does not need a real domain, public DNS, public ACME, real +mailboxes, or a connection to an external mail system. The local release label +is `v0.1.0-local-proof.1`. + +The machine-readable source for this boundary is +[`release/local-proof-scope.json`](../release/local-proof-scope.json). The +validator and its tests live in `src/release/local-proof-scope.mjs` and +`src/release/local-proof-scope.test.mjs`; `npm run test:lp0` and +`npm run test:lp0:audit` are the two convenient entry points. + +## What LP0 freezes + +### Names and certificates + +- `gulogulo.test` is the synthetic tenant/domain name. +- `webmail.localhost`, `calendar.localhost`, and `contacts.localhost` are + local browser endpoints. +- `.test` and `.localhost` are reserved for this rehearsal. They must not be + replaced with a real public domain during LP1–LP9. +- TLS is terminated with a locally trusted CA generated for the proof. Public + Let's Encrypt and generic public ACME are explicitly disabled here. +- Host-file or local DNS entries may point these names at loopback or the + private Compose/Kubernetes test network. No public DNS record is created. + +### Data and identities + +- Every account, message, attachment, calendar object, contact, quota, and + audit event is synthetic. +- No production credentials, private keys, mailbox exports, real recipient + addresses, or user-generated personal data may enter the proof. +- LDAP and PostgreSQL are local disposable dependencies in LP1 and later. They + are not the provider's external identity or application database. +- The local tenant remains isolated from every other test run by project name, + network, and external-volume namespace. + +### Runtime network boundary + +The build may download pinned or latest-stable build inputs when the operator +explicitly runs a build. Once the proof is running, application and dependency +containers use an `offline_runtime` policy: no Internet egress, no public DNS, +no public ACME challenges, and no access to a Docker socket. A local network is +allowed only for the declared Gulo Gulo services and their health checks. + +The policy is intentionally stronger than “we promise not to send mail”. It +prevents an accidental update check, telemetry call, public certificate +request, or real SMTP delivery from turning a local test into an external +operation. + +## Service inventory for LP1–LP9 + +LP0 freezes the inventory; it does not claim that each service is already +implemented. The local proof will add and exercise these components in later +milestones: + +| Service | Local proof role | +|---|---| +| `gulogulo` | WebWare/API, session, tenant and read-only monitoring surface | +| `ldap` | Disposable local identity directory | +| `postgresql` | Disposable local application state and quota ledger | +| `postfix` | Local SMTP ingress/egress simulation with no public delivery | +| `dovecot` | Local IMAP and IMAP IDLE mailbox behavior | +| `rspamd` | Local message-scanning verdicts and queue metadata | +| `clamav` | Local antivirus scanning contract and safe failure mode | +| `caldav` | Local calendar protocol endpoint | +| `carddav` | Local address-book protocol endpoint | +| `local-dns` | Reserved-name resolution inside the test boundary | +| `local-ca` | Disposable trust root and endpoint certificates | + +The implementation can package more than one logical role in a container when +that is useful for the first rehearsal, but the role boundaries and health +contracts remain visible. The final provider deployment may split them again. + +## Architecture targets + +Images and Compose/Kubernetes manifests target both `linux/amd64` (x86_64) and +`linux/arm64`. A developer may run only the host architecture locally; CI still +validates both image targets. The LP0 manifest is the source of truth for this +platform pair and the local-only release identity. + +## Acceptance criteria + +LP0 is complete when all of these remain true in a clean checkout: + +1. `release/local-proof-scope.json` validates without network access. +2. The manifest declares only reserved local names and synthetic data. +3. Public DNS and public ACME are false, and the runtime policy is + `offline_runtime`. +4. The required service inventory and both architecture targets are complete. +5. The LP0 test and audit commands pass locally and in the pull-request and + post-merge GitHub Actions gates. +6. No container is started and no external system is contacted as part of LP0. + +## Explicitly deferred + +The following belong to a later external/public phase and must not be smuggled +into the local proof: + +- registration or use of a real domain and public DNS; +- public ACME/Let's Encrypt challenges and public certificate renewal; +- delivery to or acceptance from real Internet mail systems; +- production LDAP, PostgreSQL, storage, backup, or monitoring endpoints; +- public registry publication, signed production image release, and live + Kubernetes traffic switching; +- production RPO/RTO evidence, incident rehearsal, and standard-client testing + against real provider infrastructure. + +When that phase is eventually approved, it gets a separate release identity and +separate evidence. It does not overwrite the local proof manifest. diff --git a/package.json b/package.json index 4440e83..299951c 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,14 @@ "build:web": "node web/build.mjs", "typecheck": "tsc --project tsconfig.json --noEmit", "test:web": "npm run build:web && node web/test/web-shell.test.mjs", - "test": "npm run test:web && npm run test:m6 && npm run test:m7 && npm run test:m8 && npm run test:m9 && npm run test:m10 && node src/runtime/runtime.test.mjs && node src/foundation/config.test.mjs && node src/runtime/observability.test.mjs && node src/integrations/tenant-context.test.mjs && node src/integrations/ldap-client.test.mjs && node src/integrations/postgres-store.test.mjs && node src/mail/mail-core.test.mjs && node src/web/security/security.test.mjs && node src/web/content/email-content.test.mjs && node src/web/content/attachment-policy.test.mjs && node src/web/content/timezone.test.mjs && node src/web/realtime/event-normalizer.test.mjs && node src/web/backup/backup-request.test.mjs && node src/dav/caldav/caldav-contract.test.mjs && node src/dav/carddav/carddav-store.test.mjs && node src/dav/discovery/index.test.mjs", + "test": "npm run test:web && npm run test:m6 && npm run test:m7 && npm run test:m8 && npm run test:m9 && npm run test:m10 && npm run test:lp0 && node src/runtime/runtime.test.mjs && node src/foundation/config.test.mjs && node src/runtime/observability.test.mjs && node src/integrations/tenant-context.test.mjs && node src/integrations/ldap-client.test.mjs && node src/integrations/postgres-store.test.mjs && node src/mail/mail-core.test.mjs && node src/web/security/security.test.mjs && node src/web/content/email-content.test.mjs && node src/web/content/attachment-policy.test.mjs && node src/web/content/timezone.test.mjs && node src/web/realtime/event-normalizer.test.mjs && node src/web/backup/backup-request.test.mjs && node src/dav/caldav/caldav-contract.test.mjs && node src/dav/carddav/carddav-store.test.mjs && node src/dav/discovery/index.test.mjs", "test:m6": "node src/admin/rbac.test.mjs && node src/admin/delegation.test.mjs && node src/admin/quota.test.mjs && node src/admin/admin-tools.test.mjs && node src/auth/auth.test.mjs", "test:m7": "node src/lifecycle/retention.test.mjs && node src/lifecycle/account-lifecycle.test.mjs && node src/backup/backup-contract.test.mjs && node src/observability/observability.test.mjs", "test:m8": "node src/ops/acme/index.test.mjs && node src/ops/abuse/index.test.mjs", "test:m9": "node src/upgrade/upgrade-contract.test.mjs", "test:m10": "node src/release/release-evidence.test.mjs && node scripts/m10-release-audit.mjs", + "test:lp0": "node src/release/local-proof-scope.test.mjs && node scripts/lp0-scope-audit.mjs", + "test:lp0:audit": "node scripts/lp0-scope-audit.mjs", "test:m2:postgres": "node src/integrations/postgres.integration.test.mjs" }, "dependencies": { diff --git a/release/local-proof-scope.json b/release/local-proof-scope.json new file mode 100644 index 0000000..ac56ee9 --- /dev/null +++ b/release/local-proof-scope.json @@ -0,0 +1,38 @@ +{ + "$comment": "SPDX-License-Identifier: MIT; SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net); Author: Sythos (https://www.sythos.net)", + "spdxLicenseIdentifier": "MIT", + "spdxFileCopyrightText": "2026 Sythos (https://www.sythos.net)", + "author": "Sythos (https://www.sythos.net)", + "schemaVersion": 1, + "proofType": "local", + "releaseLabel": "v0.1.0-local-proof.1", + "networkPolicy": "offline_runtime", + "syntheticDataOnly": true, + "publicDnsRequired": false, + "publicAcmeEnabled": false, + "targetPlatforms": [ + "linux/amd64", + "linux/arm64" + ], + "localNames": [ + "gulogulo.test", + "webmail.localhost", + "calendar.localhost", + "contacts.localhost" + ], + "requiredServices": [ + "gulogulo", + "ldap", + "postgresql", + "postfix", + "dovecot", + "rspamd", + "clamav", + "caldav", + "carddav", + "local-dns", + "local-ca" + ], + "externalPhaseDeferred": true, + "status": "frozen" +} diff --git a/scripts/lp0-scope-audit.mjs b/scripts/lp0-scope-audit.mjs new file mode 100644 index 0000000..5160bc5 --- /dev/null +++ b/scripts/lp0-scope-audit.mjs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { createLocalProofScope } from '../src/release/local-proof-scope.mjs'; + +const manifestPath = resolve(process.cwd(), 'release/local-proof-scope.json'); +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +const scope = createLocalProofScope(manifest); + +console.log(JSON.stringify({ + proofType: scope.proofType, + releaseLabel: scope.releaseLabel, + networkPolicy: scope.networkPolicy, + syntheticDataOnly: scope.syntheticDataOnly, + publicDnsRequired: scope.publicDnsRequired, + publicAcmeEnabled: scope.publicAcmeEnabled, + targetPlatforms: scope.targetPlatforms, + localNames: scope.localNames, + requiredServices: scope.requiredServices, + externalPhaseDeferred: scope.externalPhaseDeferred, + status: scope.status, +}, null, 2)); diff --git a/src/release/index.mjs b/src/release/index.mjs index ff9ea4e..46a5231 100644 --- a/src/release/index.mjs +++ b/src/release/index.mjs @@ -16,3 +16,10 @@ export { createReleaseEvidence, evaluateReleaseEvidence, } from './release-evidence.mjs'; + +export { + LOCAL_PROOF_RELEASE_LABEL, + LOCAL_PROOF_REQUIRED_SERVICES, + PLATFORM_SET, + createLocalProofScope, +} from './local-proof-scope.mjs'; diff --git a/src/release/local-proof-scope.mjs b/src/release/local-proof-scope.mjs new file mode 100644 index 0000000..735e118 --- /dev/null +++ b/src/release/local-proof-scope.mjs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +/** + * LP0 local-proof scope contract. + * + * LP0 freezes the boundary for the local deployment rehearsal. It does not + * start services or claim that a local fixture is a production provider. + */ + +const LOCAL_PROOF_RELEASE_PATTERN = /^v\d+\.\d+\.\d+-local-proof\.\d+$/; +const LOCAL_NAME_PATTERN = /^(?:[a-z0-9-]+\.localhost|[a-z0-9-]+\.test)$/; +const PLATFORM_SET = Object.freeze(['linux/amd64', 'linux/arm64']); + +const LOCAL_PROOF_REQUIRED_SERVICES = Object.freeze([ + 'gulogulo', + 'ldap', + 'postgresql', + 'postfix', + 'dovecot', + 'rspamd', + 'clamav', + 'caldav', + 'carddav', + 'local-dns', + 'local-ca', +]); + +const LOCAL_PROOF_RELEASE_LABEL = 'v0.1.0-local-proof.1'; + +function scopeError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +function assertPlainObject(value, code, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw scopeError(code, `${label} must be a plain object.`); + } +} + +function assertExactString(value, expected, code, label) { + if (value !== expected) { + throw scopeError(code, `${label} must be ${expected}.`); + } +} + +function assertSafeString(value, code, label) { + if (typeof value !== 'string' || value.length === 0 || /[\\/\0]|\.\.|https?:\/\//i.test(value)) { + throw scopeError(code, `${label} contains an unsafe value.`); + } +} + +function assertPlatformList(platforms) { + if (!Array.isArray(platforms) || platforms.length !== PLATFORM_SET.length || + platforms.some((platform, index) => platform !== PLATFORM_SET[index])) { + throw scopeError('LP0_PLATFORMS_INVALID', 'LP0 must declare linux/amd64 and linux/arm64 in order.'); + } +} + +function assertLocalNames(localNames) { + if (!Array.isArray(localNames) || localNames.length === 0) { + throw scopeError('LP0_LOCAL_NAMES_EMPTY', 'LP0 must declare at least one local-only name.'); + } + for (const name of localNames) { + if (typeof name !== 'string' || !LOCAL_NAME_PATTERN.test(name)) { + throw scopeError('LP0_LOCAL_NAME_INVALID', `The local name ${String(name)} is not a reserved test name.`); + } + } +} + +function assertRequiredServices(requiredServices) { + if (!Array.isArray(requiredServices) || requiredServices.length !== LOCAL_PROOF_REQUIRED_SERVICES.length || + requiredServices.some((service, index) => service !== LOCAL_PROOF_REQUIRED_SERVICES[index])) { + throw scopeError('LP0_SERVICES_INVALID', 'LP0 required services must match the frozen local service inventory.'); + } +} + +/** + * Validate and freeze the local-proof manifest. + * + * The validator deliberately rejects public DNS, public ACME, real secrets, + * and absolute paths so the local proof cannot quietly become a deployment. + */ +export function createLocalProofScope(input) { + assertPlainObject(input, 'LP0_SCOPE_INVALID', 'LP0 scope'); + assertExactString(input.proofType, 'local', 'LP0_PROOF_TYPE_INVALID', 'proofType'); + if (input.schemaVersion !== 1) { + throw scopeError('LP0_SCHEMA_INVALID', 'LP0 scope schemaVersion must be 1.'); + } + if (typeof input.releaseLabel !== 'string' || !LOCAL_PROOF_RELEASE_PATTERN.test(input.releaseLabel)) { + throw scopeError('LP0_RELEASE_LABEL_INVALID', 'LP0 releaseLabel must use the local-proof version pattern.'); + } + assertExactString(input.networkPolicy, 'offline_runtime', 'LP0_NETWORK_POLICY_INVALID', 'networkPolicy'); + if (input.syntheticDataOnly !== true || input.publicDnsRequired !== false || input.publicAcmeEnabled !== false) { + throw scopeError('LP0_EXTERNAL_BOUNDARY_INVALID', 'LP0 requires synthetic data, no public DNS, and no public ACME.'); + } + if (input.externalPhaseDeferred !== true || input.status !== 'frozen') { + throw scopeError('LP0_STATUS_INVALID', 'LP0 must remain frozen while the external phase is deferred.'); + } + assertPlatformList(input.targetPlatforms); + assertLocalNames(input.localNames); + assertRequiredServices(input.requiredServices); + assertSafeString(input.spdxLicenseIdentifier, 'LP0_METADATA_INVALID', 'spdxLicenseIdentifier'); + assertExactString(input.spdxLicenseIdentifier, 'MIT', 'LP0_LICENSE_INVALID', 'spdxLicenseIdentifier'); + assertExactString(input.spdxFileCopyrightText, '2026 Sythos (https://www.sythos.net)', 'LP0_METADATA_INVALID', 'spdxFileCopyrightText'); + assertExactString(input.author, 'Sythos (https://www.sythos.net)', 'LP0_METADATA_INVALID', 'author'); + + return Object.freeze({ + ...input, + targetPlatforms: Object.freeze([...input.targetPlatforms]), + localNames: Object.freeze([...input.localNames]), + requiredServices: Object.freeze([...input.requiredServices]), + }); +} + +export { LOCAL_PROOF_RELEASE_LABEL, LOCAL_PROOF_REQUIRED_SERVICES, PLATFORM_SET }; diff --git a/src/release/local-proof-scope.test.mjs b/src/release/local-proof-scope.test.mjs new file mode 100644 index 0000000..9c62097 --- /dev/null +++ b/src/release/local-proof-scope.test.mjs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + LOCAL_PROOF_RELEASE_LABEL, + LOCAL_PROOF_REQUIRED_SERVICES, + createLocalProofScope, +} from './local-proof-scope.mjs'; + +const VALID_SCOPE = { + spdxLicenseIdentifier: 'MIT', + spdxFileCopyrightText: '2026 Sythos (https://www.sythos.net)', + author: 'Sythos (https://www.sythos.net)', + schemaVersion: 1, + proofType: 'local', + releaseLabel: LOCAL_PROOF_RELEASE_LABEL, + networkPolicy: 'offline_runtime', + syntheticDataOnly: true, + publicDnsRequired: false, + publicAcmeEnabled: false, + targetPlatforms: ['linux/amd64', 'linux/arm64'], + localNames: ['gulogulo.test', 'webmail.localhost', 'calendar.localhost', 'contacts.localhost'], + requiredServices: [...LOCAL_PROOF_REQUIRED_SERVICES], + externalPhaseDeferred: true, + status: 'frozen', +}; + +test('LP0 accepts the frozen, offline, synthetic local scope', () => { + const scope = createLocalProofScope(VALID_SCOPE); + + assert.equal(scope.releaseLabel, LOCAL_PROOF_RELEASE_LABEL); + assert.equal(scope.networkPolicy, 'offline_runtime'); + assert.equal(scope.syntheticDataOnly, true); + assert.deepEqual(scope.targetPlatforms, ['linux/amd64', 'linux/arm64']); + assert(Object.isFrozen(scope)); + assert(Object.isFrozen(scope.requiredServices)); +}); + +test('LP0 rejects public-network and public-ACME claims', () => { + assert.throws( + () => createLocalProofScope({ ...VALID_SCOPE, publicDnsRequired: true }), + (error) => error.code === 'LP0_EXTERNAL_BOUNDARY_INVALID', + ); + assert.throws( + () => createLocalProofScope({ ...VALID_SCOPE, publicAcmeEnabled: true }), + (error) => error.code === 'LP0_EXTERNAL_BOUNDARY_INVALID', + ); +}); + +test('LP0 rejects non-reserved names and incomplete service inventories', () => { + assert.throws( + () => createLocalProofScope({ ...VALID_SCOPE, localNames: ['mail.example.com'] }), + (error) => error.code === 'LP0_LOCAL_NAME_INVALID', + ); + assert.throws( + () => createLocalProofScope({ ...VALID_SCOPE, requiredServices: VALID_SCOPE.requiredServices.slice(1) }), + (error) => error.code === 'LP0_SERVICES_INVALID', + ); +}); + +test('LP0 rejects absolute paths and unsafe release labels', () => { + assert.throws( + () => createLocalProofScope({ ...VALID_SCOPE, releaseLabel: 'v0.1.0-local-proof.1/secret' }), + (error) => error.code === 'LP0_RELEASE_LABEL_INVALID', + ); + assert.throws( + () => createLocalProofScope({ ...VALID_SCOPE, author: 'C:\\Users\\Sythos' }), + (error) => error.code === 'LP0_METADATA_INVALID', + ); +});