@clearance/cli 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ export type OperatorCredential = {
2
+ version: 1;
3
+ apiUrl: string;
4
+ token: string;
5
+ };
6
+ export type OperatorWhoami = {
7
+ operator: {
8
+ id: string;
9
+ type: "operator";
10
+ authenticated: true;
11
+ };
12
+ projectId: string;
13
+ environmentId: string;
14
+ storeBackend: "json" | "postgres";
15
+ };
16
+ type AuthEnvironment = Partial<Pick<NodeJS.ProcessEnv, "CLEARANCE_CLI_CONFIG_DIR" | "XDG_CONFIG_HOME" | "HOME" | "CLEARANCE_API_URL" | "CLEARANCE_OPERATOR_TOKEN" | "CLEARANCE_API_TOKEN" | "CLEARANCE_PROFILE">>;
17
+ export declare function credentialDirectory(env?: AuthEnvironment): string;
18
+ export declare function normalizeProfile(profile: string | undefined, env?: AuthEnvironment): string;
19
+ export declare function credentialPath(env?: AuthEnvironment, profile?: string): string;
20
+ export declare function normalizeApiUrl(candidate: string | undefined, env?: AuthEnvironment): string;
21
+ export declare function environmentToken(env?: AuthEnvironment): string | undefined;
22
+ export declare function readSavedCredential(env?: AuthEnvironment, profile?: string): Promise<OperatorCredential | undefined>;
23
+ export declare function writeSavedCredential(credential: Omit<OperatorCredential, "version">, env?: AuthEnvironment, profile?: string): Promise<void>;
24
+ export declare function deleteSavedCredential(env?: AuthEnvironment, profile?: string): Promise<boolean>;
25
+ export declare function readTokenFromStdin(): Promise<string>;
26
+ export declare function fetchWhoami(apiUrl: string, token: string): Promise<OperatorWhoami>;
27
+ export declare function validateAndSaveCredential(apiUrl: string, token: string, env?: AuthEnvironment, profile?: string): Promise<OperatorWhoami>;
28
+ export {};
@@ -0,0 +1,310 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmod, lstat, mkdir, open, rename, unlink, } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { constants } from "node:fs";
6
+ import { ClearanceError } from "@clearance/management";
7
+ const CREDENTIAL_FILENAME = "operator-credentials.json";
8
+ const CREDENTIAL_VERSION = 1;
9
+ const WHOAMI_TIMEOUT_MS = 5_000;
10
+ const MAX_STDIN_TOKEN_BYTES = 16 * 1024;
11
+ const MIN_TOKEN_BYTES = 16;
12
+ const MAX_CREDENTIAL_FILE_BYTES = 32 * 1024;
13
+ function error(code, message, remediation, retryable = false) {
14
+ return new ClearanceError({
15
+ code,
16
+ message,
17
+ stage: "operator-auth",
18
+ remediation,
19
+ retryable,
20
+ });
21
+ }
22
+ function credentialIoError(cause) {
23
+ if (cause instanceof ClearanceError)
24
+ return cause;
25
+ return error("CLI_CREDENTIAL_IO", "Saved credential storage could not be accessed safely.", "Check CLEARANCE_CLI_CONFIG_DIR permissions and try again.");
26
+ }
27
+ function fileMode(mode) {
28
+ return mode & 0o777;
29
+ }
30
+ async function requirePrivateDirectory(path, create) {
31
+ try {
32
+ const stat = await lstat(path);
33
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
34
+ throw error("CLI_CREDENTIAL_DIRECTORY_UNSAFE", "Credential directory is not a regular private directory.", "Remove the unsafe credential directory and run clearance login again.");
35
+ }
36
+ if (fileMode(stat.mode) !== 0o700) {
37
+ throw error("CLI_CREDENTIAL_DIRECTORY_UNSAFE", "Credential directory permissions must be 0700.", "Restrict the credential directory to its owner and run clearance login again.");
38
+ }
39
+ return;
40
+ }
41
+ catch (cause) {
42
+ if (cause instanceof ClearanceError)
43
+ throw cause;
44
+ if (cause.code !== "ENOENT" || !create)
45
+ throw cause;
46
+ }
47
+ await mkdir(path, { recursive: true, mode: 0o700 });
48
+ const beforeChmod = await lstat(path);
49
+ if (beforeChmod.isSymbolicLink() || !beforeChmod.isDirectory()) {
50
+ throw error("CLI_CREDENTIAL_DIRECTORY_UNSAFE", "Credential directory could not be secured.", "Set a private CLEARANCE_CLI_CONFIG_DIR and retry.");
51
+ }
52
+ await chmod(path, 0o700);
53
+ const stat = await lstat(path);
54
+ if (stat.isSymbolicLink() || !stat.isDirectory() || fileMode(stat.mode) !== 0o700) {
55
+ throw error("CLI_CREDENTIAL_DIRECTORY_UNSAFE", "Credential directory could not be secured.", "Set a private CLEARANCE_CLI_CONFIG_DIR and retry.");
56
+ }
57
+ }
58
+ async function requirePrivateCredentialFile(path) {
59
+ const stat = await lstat(path);
60
+ if (stat.isSymbolicLink() || !stat.isFile() || fileMode(stat.mode) !== 0o600) {
61
+ throw error("CLI_CREDENTIAL_FILE_UNSAFE", "Saved credential file is not a regular file with mode 0600.", "Remove the unsafe credential file and log in again.");
62
+ }
63
+ }
64
+ export function credentialDirectory(env = process.env) {
65
+ const explicit = env.CLEARANCE_CLI_CONFIG_DIR?.trim();
66
+ if (explicit)
67
+ return resolve(explicit);
68
+ const xdg = env.XDG_CONFIG_HOME?.trim();
69
+ if (xdg)
70
+ return resolve(xdg, "clearance");
71
+ return join(env.HOME?.trim() || homedir(), ".config", "clearance");
72
+ }
73
+ export function normalizeProfile(profile, env = process.env) {
74
+ const value = profile?.trim() || env.CLEARANCE_PROFILE?.trim() || "default";
75
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(value)) {
76
+ throw error("CLI_PROFILE_INVALID", "Clearance profile names must be lowercase letters, numbers, or hyphens.", "Choose a profile slug such as default, staging, or production-us.");
77
+ }
78
+ return value;
79
+ }
80
+ export function credentialPath(env = process.env, profile) {
81
+ const selected = normalizeProfile(profile, env);
82
+ const filename = selected === "default" ? CREDENTIAL_FILENAME : `operator-credentials.${selected}.json`;
83
+ return join(credentialDirectory(env), filename);
84
+ }
85
+ function isLoopbackHost(hostname) {
86
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
87
+ return host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host);
88
+ }
89
+ export function normalizeApiUrl(candidate, env = process.env) {
90
+ const value = candidate?.trim() || env.CLEARANCE_API_URL?.trim() || "http://localhost:3200";
91
+ let url;
92
+ try {
93
+ url = new URL(value);
94
+ }
95
+ catch {
96
+ throw error("CLI_API_URL_INVALID", "Clearance API URL is invalid.", "Pass a valid --url or set CLEARANCE_API_URL.");
97
+ }
98
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
99
+ throw error("CLI_API_URL_INVALID", "Clearance API URL must use HTTPS or local HTTP.", "Use an HTTPS URL or a localhost loopback HTTP URL.");
100
+ }
101
+ if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) {
102
+ throw error("CLI_API_URL_INSECURE", "Remote Clearance API URLs must use HTTPS.", "Use HTTPS for remote APIs; HTTP is allowed only for localhost loopback.");
103
+ }
104
+ if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) {
105
+ throw error("CLI_API_URL_INVALID", "Clearance API URL must be an origin without credentials, path, query, or fragment.", "Pass the API origin, for example https://api.example.com.");
106
+ }
107
+ return url.origin;
108
+ }
109
+ export function environmentToken(env = process.env) {
110
+ return env.CLEARANCE_OPERATOR_TOKEN?.trim() || env.CLEARANCE_API_TOKEN?.trim() || undefined;
111
+ }
112
+ function validateOperatorToken(token) {
113
+ const size = Buffer.byteLength(token, "utf8");
114
+ if (size < MIN_TOKEN_BYTES || size > MAX_STDIN_TOKEN_BYTES || /\s/.test(token)) {
115
+ throw error("CLI_TOKEN_INVALID", "Operator token is invalid.", "Provide a bearer token between 16 bytes and 16 KiB without whitespace.");
116
+ }
117
+ return token;
118
+ }
119
+ function parseCredential(raw) {
120
+ let value;
121
+ try {
122
+ value = JSON.parse(raw);
123
+ }
124
+ catch {
125
+ throw error("CLI_CREDENTIAL_INVALID", "Saved credential file is not valid JSON.", "Log in again to create a new credential file.");
126
+ }
127
+ if (!value ||
128
+ typeof value !== "object" ||
129
+ Array.isArray(value) ||
130
+ Object.keys(value).length !== 3 ||
131
+ !["version", "apiUrl", "token"].every((key) => Object.prototype.hasOwnProperty.call(value, key))) {
132
+ throw error("CLI_CREDENTIAL_INVALID", "Saved credential file has an invalid schema.", "Log in again to create a new credential file.");
133
+ }
134
+ const credential = value;
135
+ if (credential.version !== CREDENTIAL_VERSION ||
136
+ typeof credential.apiUrl !== "string" ||
137
+ typeof credential.token !== "string") {
138
+ throw error("CLI_CREDENTIAL_INVALID", "Saved credential file has an invalid schema.", "Log in again to create a new credential file.");
139
+ }
140
+ try {
141
+ validateOperatorToken(credential.token);
142
+ }
143
+ catch {
144
+ throw error("CLI_CREDENTIAL_INVALID", "Saved credential file has an invalid schema.", "Log in again to create a new credential file.");
145
+ }
146
+ return {
147
+ version: CREDENTIAL_VERSION,
148
+ apiUrl: normalizeApiUrl(credential.apiUrl, {}),
149
+ token: credential.token,
150
+ };
151
+ }
152
+ export async function readSavedCredential(env = process.env, profile) {
153
+ const directory = credentialDirectory(env);
154
+ const path = credentialPath(env, profile);
155
+ try {
156
+ await requirePrivateDirectory(directory, false);
157
+ }
158
+ catch (cause) {
159
+ if (cause.code === "ENOENT")
160
+ return undefined;
161
+ throw credentialIoError(cause);
162
+ }
163
+ try {
164
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
165
+ try {
166
+ const stat = await handle.stat();
167
+ if (!stat.isFile() || fileMode(stat.mode) !== 0o600 || stat.size > MAX_CREDENTIAL_FILE_BYTES) {
168
+ throw error("CLI_CREDENTIAL_FILE_UNSAFE", "Saved credential file is not a regular, bounded file with mode 0600.", "Remove the unsafe credential file and log in again.");
169
+ }
170
+ return parseCredential(await handle.readFile("utf8"));
171
+ }
172
+ finally {
173
+ await handle.close();
174
+ }
175
+ }
176
+ catch (cause) {
177
+ if (cause.code === "ENOENT")
178
+ return undefined;
179
+ throw credentialIoError(cause);
180
+ }
181
+ }
182
+ export async function writeSavedCredential(credential, env = process.env, profile) {
183
+ const directory = credentialDirectory(env);
184
+ const selectedProfile = normalizeProfile(profile, env);
185
+ const path = credentialPath(env, selectedProfile);
186
+ try {
187
+ await requirePrivateDirectory(directory, true);
188
+ }
189
+ catch (cause) {
190
+ throw credentialIoError(cause);
191
+ }
192
+ try {
193
+ await requirePrivateCredentialFile(path);
194
+ }
195
+ catch (cause) {
196
+ if (cause.code !== "ENOENT")
197
+ throw credentialIoError(cause);
198
+ }
199
+ const normalized = {
200
+ version: CREDENTIAL_VERSION,
201
+ apiUrl: normalizeApiUrl(credential.apiUrl, {}),
202
+ token: validateOperatorToken(credential.token),
203
+ };
204
+ const temporary = join(directory, `.${selectedProfile}.${CREDENTIAL_FILENAME}.${randomBytes(16).toString("hex")}.tmp`);
205
+ let handle;
206
+ try {
207
+ handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
208
+ await handle.writeFile(`${JSON.stringify(normalized)}\n`, "utf8");
209
+ await handle.sync();
210
+ await handle.close();
211
+ handle = undefined;
212
+ await rename(temporary, path);
213
+ await requirePrivateCredentialFile(path);
214
+ }
215
+ catch (cause) {
216
+ await handle?.close().catch(() => undefined);
217
+ await unlink(temporary).catch(() => undefined);
218
+ throw credentialIoError(cause);
219
+ }
220
+ }
221
+ export async function deleteSavedCredential(env = process.env, profile) {
222
+ const directory = credentialDirectory(env);
223
+ const path = credentialPath(env, profile);
224
+ try {
225
+ await requirePrivateDirectory(directory, false);
226
+ }
227
+ catch (cause) {
228
+ if (cause.code === "ENOENT")
229
+ return false;
230
+ throw credentialIoError(cause);
231
+ }
232
+ try {
233
+ await requirePrivateCredentialFile(path);
234
+ await unlink(path);
235
+ return true;
236
+ }
237
+ catch (cause) {
238
+ if (cause.code === "ENOENT")
239
+ return false;
240
+ throw credentialIoError(cause);
241
+ }
242
+ }
243
+ export async function readTokenFromStdin() {
244
+ let input = "";
245
+ for await (const chunk of process.stdin) {
246
+ input += typeof chunk === "string" ? chunk : chunk.toString("utf8");
247
+ if (Buffer.byteLength(input, "utf8") > MAX_STDIN_TOKEN_BYTES) {
248
+ throw error("CLI_TOKEN_INVALID", "Operator token input is too large.", "Provide one bearer token on standard input.");
249
+ }
250
+ }
251
+ return validateOperatorToken(input.trim());
252
+ }
253
+ function parseWhoami(value) {
254
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
255
+ throw error("CLI_WHOAMI_INVALID_RESPONSE", "Clearance API returned an invalid whoami response.", "Upgrade the Clearance API or check the configured API URL.");
256
+ }
257
+ const response = value;
258
+ const operator = response.operator;
259
+ if (!operator ||
260
+ operator.id !== "operator" ||
261
+ operator.type !== "operator" ||
262
+ operator.authenticated !== true ||
263
+ typeof response.projectId !== "string" ||
264
+ !response.projectId ||
265
+ typeof response.environmentId !== "string" ||
266
+ !response.environmentId ||
267
+ (response.storeBackend !== "json" && response.storeBackend !== "postgres")) {
268
+ throw error("CLI_WHOAMI_INVALID_RESPONSE", "Clearance API returned an invalid whoami response.", "Upgrade the Clearance API or check the configured API URL.");
269
+ }
270
+ return {
271
+ operator: { id: "operator", type: "operator", authenticated: true },
272
+ projectId: response.projectId,
273
+ environmentId: response.environmentId,
274
+ storeBackend: response.storeBackend,
275
+ };
276
+ }
277
+ export async function fetchWhoami(apiUrl, token) {
278
+ const validatedToken = validateOperatorToken(token);
279
+ const controller = new AbortController();
280
+ const timer = setTimeout(() => controller.abort(), WHOAMI_TIMEOUT_MS);
281
+ try {
282
+ const response = await fetch(`${normalizeApiUrl(apiUrl, {})}/v1/whoami`, {
283
+ headers: { authorization: `Bearer ${validatedToken}`, accept: "application/json" },
284
+ signal: controller.signal,
285
+ });
286
+ if (response.status === 401) {
287
+ throw error("CLI_AUTH_UNAUTHORIZED", "Clearance API rejected the operator credential.", "Provide a valid operator token and try again.");
288
+ }
289
+ if (!response.ok) {
290
+ throw error("CLI_WHOAMI_FAILED", "Clearance API could not verify the operator credential.", "Check the API URL and operator access, then try again.", response.status >= 500);
291
+ }
292
+ return parseWhoami(await response.json());
293
+ }
294
+ catch (cause) {
295
+ if (cause instanceof ClearanceError)
296
+ throw cause;
297
+ if (cause.name === "AbortError") {
298
+ throw error("CLI_API_TIMEOUT", "Clearance API verification timed out.", "Check API reachability and try again.", true);
299
+ }
300
+ throw error("CLI_API_UNREACHABLE", "Clearance API could not be reached.", "Check the API URL and network connection, then try again.", true);
301
+ }
302
+ finally {
303
+ clearTimeout(timer);
304
+ }
305
+ }
306
+ export async function validateAndSaveCredential(apiUrl, token, env = process.env, profile) {
307
+ const whoami = await fetchWhoami(apiUrl, token);
308
+ await writeSavedCredential({ apiUrl, token }, env, profile);
309
+ return whoami;
310
+ }
@@ -0,0 +1,129 @@
1
+ # Production overlay for Clearance.
2
+ #
3
+ # Usage (after validating secrets):
4
+ # scripts/validate-production-env.sh
5
+ # docker compose -f docker-compose.yml -f deploy/compose/docker-compose.production.yml config
6
+ # docker compose -f docker-compose.yml -f deploy/compose/docker-compose.production.yml up -d
7
+ #
8
+ # Fail-closed rules:
9
+ # - NODE_ENV is forced to production
10
+ # - DATABASE_URL must be a complete, operator-supplied connection string
11
+ # (no shell construction of password-bearing URLs — avoids @:/#? interpolation breakage)
12
+ # - Every secret is required with no defaults
13
+ # - Postgres is NOT published to the host by default (ports reset)
14
+ # - Credential encryption key + key id are mandatory
15
+ # - No VAR-with-default secret fallbacks (only fail-closed ${VAR:?...} forms)
16
+ services:
17
+ postgres:
18
+ environment:
19
+ POSTGRES_USER: ${CLEARANCE_DB_USER:?set CLEARANCE_DB_USER}
20
+ POSTGRES_PASSWORD: ${CLEARANCE_DB_PASSWORD:?set CLEARANCE_DB_PASSWORD}
21
+ POSTGRES_DB: ${CLEARANCE_DB_NAME:?set CLEARANCE_DB_NAME}
22
+ # Production default: internal network only. Host publish requires a local override file.
23
+ ports: !reset []
24
+ volumes:
25
+ - clearance_pg:/var/lib/postgresql/data
26
+ healthcheck:
27
+ # $$ defers expansion to the container shell (avoids host-side compose interpolation)
28
+ test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
29
+ interval: 5s
30
+ timeout: 5s
31
+ retries: 12
32
+ restart: always
33
+ stop_grace_period: 30s
34
+
35
+ api:
36
+ build: !reset null
37
+ image: "${CLEARANCE_IMAGE_REPOSITORY:?set CLEARANCE_IMAGE_REPOSITORY}@${CLEARANCE_IMAGE_DIGEST:?set signed CLEARANCE_IMAGE_DIGEST}"
38
+ environment:
39
+ NODE_ENV: production
40
+ CLEARANCE_STRICT_SECRETS: "1"
41
+ CLEARANCE_API_PORT: "3200"
42
+ CLEARANCE_OPERATOR_TOKEN: ${CLEARANCE_OPERATOR_TOKEN:?set CLEARANCE_OPERATOR_TOKEN}
43
+ CLEARANCE_SECRET: ${CLEARANCE_SECRET:?set CLEARANCE_SECRET}
44
+ CLEARANCE_CREDENTIAL_KEY: ${CLEARANCE_CREDENTIAL_KEY:?set CLEARANCE_CREDENTIAL_KEY}
45
+ CLEARANCE_CREDENTIAL_KEY_ID: ${CLEARANCE_CREDENTIAL_KEY_ID:?set CLEARANCE_CREDENTIAL_KEY_ID}
46
+ CLEARANCE_BASE_URL: ${CLEARANCE_BASE_URL:?set CLEARANCE_BASE_URL}
47
+ CLEARANCE_CONSOLE_URL: ${CLEARANCE_CONSOLE_URL:?set CLEARANCE_CONSOLE_URL}
48
+ CLEARANCE_CORS_ORIGINS: ${CLEARANCE_CORS_ORIGINS:?set CLEARANCE_CORS_ORIGINS}
49
+ # Full connection string required — do not embed raw passwords via compose interpolation.
50
+ DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL to a full postgres URL with percent-encoded password}
51
+ # Fail-closed override of the dev base file: this profile publishes the
52
+ # API port, so x-forwarded-for from arbitrary clients must NOT be
53
+ # trusted for rate-limit keying. Hardcoded (this overlay forbids
54
+ # ${VAR:-default} interpolation); edit this line to "1" only if your
55
+ # deployment makes the API reachable EXCLUSIVELY via trusted proxies
56
+ # (e.g. the console BFF or a fronting load balancer).
57
+ CLEARANCE_TRUSTED_PROXY: "0"
58
+ # Replace the base loopback mapping instead of merging a second binding.
59
+ ports: !override
60
+ - "127.0.0.1:${CLEARANCE_API_PORT:?set CLEARANCE_API_PORT}:3200"
61
+ restart: always
62
+ stop_grace_period: 30s
63
+
64
+ console:
65
+ build: !reset null
66
+ image: "${CLEARANCE_IMAGE_REPOSITORY:?set CLEARANCE_IMAGE_REPOSITORY}@${CLEARANCE_IMAGE_DIGEST:?set signed CLEARANCE_IMAGE_DIGEST}"
67
+ environment:
68
+ NODE_ENV: production
69
+ CLEARANCE_STRICT_SECRETS: "1"
70
+ CLEARANCE_CONSOLE_PORT: "3100"
71
+ CLEARANCE_API_URL: http://api:3200
72
+ CLEARANCE_OPERATOR_TOKEN: ${CLEARANCE_OPERATOR_TOKEN:?set CLEARANCE_OPERATOR_TOKEN}
73
+ CLEARANCE_CONSOLE_ADMIN_USER: ${CLEARANCE_CONSOLE_ADMIN_USER:?set CLEARANCE_CONSOLE_ADMIN_USER}
74
+ CLEARANCE_CONSOLE_ADMIN_PASSWORD: ${CLEARANCE_CONSOLE_ADMIN_PASSWORD:?set CLEARANCE_CONSOLE_ADMIN_PASSWORD}
75
+ CLEARANCE_CONSOLE_SESSION_SECRET: ${CLEARANCE_CONSOLE_SESSION_SECRET:?set CLEARANCE_CONSOLE_SESSION_SECRET}
76
+ # Replace the base loopback mapping instead of merging a second binding.
77
+ ports: !override
78
+ - "127.0.0.1:${CLEARANCE_CONSOLE_PORT:?set CLEARANCE_CONSOLE_PORT}:3100"
79
+ restart: always
80
+ stop_grace_period: 30s
81
+
82
+ sample-b2b:
83
+ build: !reset null
84
+ image: "${CLEARANCE_IMAGE_REPOSITORY:?set CLEARANCE_IMAGE_REPOSITORY}@${CLEARANCE_IMAGE_DIGEST:?set signed CLEARANCE_IMAGE_DIGEST}"
85
+ environment:
86
+ NODE_ENV: production
87
+ CLEARANCE_STRICT_SECRETS: "1"
88
+ SAMPLE_APP_PORT: "3000"
89
+ CLEARANCE_SECRET: ${CLEARANCE_SECRET:?set CLEARANCE_SECRET}
90
+ CLEARANCE_BASE_URL: ${CLEARANCE_BASE_URL:?set CLEARANCE_BASE_URL}
91
+ # Optional social providers. The application fails startup on incomplete pairs.
92
+ CLEARANCE_GITHUB_CLIENT_ID: ${CLEARANCE_GITHUB_CLIENT_ID-}
93
+ CLEARANCE_GITHUB_CLIENT_SECRET: ${CLEARANCE_GITHUB_CLIENT_SECRET-}
94
+ CLEARANCE_GOOGLE_CLIENT_ID: ${CLEARANCE_GOOGLE_CLIENT_ID-}
95
+ CLEARANCE_GOOGLE_CLIENT_SECRET: ${CLEARANCE_GOOGLE_CLIENT_SECRET-}
96
+ DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL to a full postgres URL with percent-encoded password}
97
+ # Replace the base loopback mapping instead of merging a second binding.
98
+ ports: !override
99
+ - "127.0.0.1:${CLEARANCE_SAMPLE_PORT:?set CLEARANCE_SAMPLE_PORT}:3000"
100
+ restart: always
101
+
102
+ # One-shot scheduled job. Invoke from cron/systemd:
103
+ # docker compose ... --profile backup run --rm backup
104
+ # The script fails closed unless CLEARANCE_BACKUP_COPY_COMMAND succeeds.
105
+ backup:
106
+ profiles: ["backup"]
107
+ build: !reset null
108
+ image: "${CLEARANCE_BACKUP_IMAGE_REPOSITORY:?set CLEARANCE_BACKUP_IMAGE_REPOSITORY}@${CLEARANCE_BACKUP_IMAGE_DIGEST:?set signed CLEARANCE_BACKUP_IMAGE_DIGEST}"
109
+ command: ["bash", "scripts/backup-scheduled.sh", "--dir", "/backups"]
110
+ environment:
111
+ DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL to a full postgres URL with percent-encoded password}
112
+ CLEARANCE_BACKUP_COPY_COMMAND: ${CLEARANCE_BACKUP_COPY_COMMAND-}
113
+ CLEARANCE_BACKUP_RETENTION_DAYS: ${CLEARANCE_BACKUP_RETENTION_DAYS-30}
114
+ CLEARANCE_BACKUP_RESTORE_VERIFY: "1"
115
+ volumes:
116
+ - clearance_backups:/backups
117
+ tmpfs:
118
+ - /tmp
119
+ read_only: true
120
+ depends_on:
121
+ postgres:
122
+ condition: service_healthy
123
+ restart: "no"
124
+
125
+ volumes:
126
+ clearance_pg:
127
+ name: ${CLEARANCE_PG_VOLUME:?set CLEARANCE_PG_VOLUME to an explicit volume name}
128
+ clearance_backups:
129
+ name: ${CLEARANCE_BACKUP_VOLUME:?set CLEARANCE_BACKUP_VOLUME to an explicit volume name}
@@ -0,0 +1,24 @@
1
+ # Upgrade step hooks
2
+
3
+ `scripts/upgrade-apply.sh` requires a hook:
4
+
5
+ ```text
6
+ deploy/upgrades/steps/<targetVersion>/apply.sh
7
+ ```
8
+
9
+ The currently supported transition is `0.1.4` to `0.2.0`. Its shipped hook is
10
+ `deploy/upgrades/steps/0.2.0/apply.sh`; it verifies the release marker is at
11
+ `0.1.4`, advances it to `0.2.0`, and verifies the result. The Clearance CLI
12
+ packages this hook under `dist/ops/deploy/upgrades/steps/0.2.0/apply.sh`.
13
+
14
+ ## Contract
15
+
16
+ - Invoked only after preflight and a **verified** Postgres backup (with isolated restore verification).
17
+ - Args: `--plan PATH --from CURRENT --to TARGET`
18
+ - Exit non-zero to fail closed (no silent partial apply).
19
+ - Must not drop or overwrite the active database; destructive changes require explicit operator runbooks.
20
+ - Rollback reference is always the verified backup id recorded in the plan state sidecar.
21
+
22
+ Without a hook, apply fails after creating and verifying its rollback backup. It never records a no-op version transition as applied.
23
+
24
+ `scripts/upgrade-rollback.sh` is a rollback **drill**: it verifies the deterministic backup reference through an isolated restore and leaves the active environment unchanged. An actual rollback remains an operator-runbook action until active restore automation is designed and tested separately.
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bash
2
+ # Shipped 0.1.4 -> 0.2.0 database transition.
3
+ set -Eeuo pipefail
4
+
5
+ ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
6
+ # shellcheck source=scripts/lib/ops-common.sh
7
+ source "$ROOT/scripts/lib/ops-common.sh"
8
+
9
+ PLAN=""
10
+ FROM=""
11
+ TO=""
12
+ while [[ $# -gt 0 ]]; do
13
+ case "$1" in
14
+ --plan) PLAN="$2"; shift 2 ;;
15
+ --from) FROM="$2"; shift 2 ;;
16
+ --to) TO="$2"; shift 2 ;;
17
+ *) die "unknown upgrade hook argument: $1" ;;
18
+ esac
19
+ done
20
+
21
+ [[ -f "$PLAN" ]] || die "upgrade plan is missing"
22
+ [[ "$FROM" == "0.1.4" && "$TO" == "0.2.0" ]] \
23
+ || die "this hook only supports 0.1.4 to 0.2.0"
24
+
25
+ require_pg_client
26
+ require_cmd node
27
+ URL="$(resolve_database_url)"
28
+ PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId)' "$PLAN")"
29
+ PLAN_SHA="$(sha256_file "$PLAN")"
30
+ [[ "$PLAN_ID" =~ ^upg_[0-9TZ]+_[a-f0-9]+$ ]] || die "unsafe plan id"
31
+ require_application_release "$URL" "$FROM"
32
+
33
+ # This is the application-owned migration contract: the durable management
34
+ # snapshot used by the running API advances atomically with an append-only
35
+ # migration ledger. It cannot pass against the old test-only ops marker.
36
+ psql_q "$URL" \
37
+ -v from_version="$FROM" -v to_version="$TO" \
38
+ -v plan_id="$PLAN_ID" -v plan_sha="$PLAN_SHA" <<'SQL' >/dev/null
39
+ BEGIN;
40
+ CREATE TABLE IF NOT EXISTS clearance_schema_migrations (
41
+ version text PRIMARY KEY,
42
+ applied_at timestamptz NOT NULL DEFAULT now(),
43
+ plan_id text NOT NULL,
44
+ plan_sha256 text NOT NULL,
45
+ from_version text
46
+ );
47
+ INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
48
+ VALUES (:'from_version', 'application-baseline', :'plan_sha', NULL)
49
+ ON CONFLICT (version) DO NOTHING;
50
+ UPDATE clearance_management_snapshot
51
+ SET data = jsonb_set(data, '{releaseVersion}', to_jsonb(:'to_version'::text), false),
52
+ revision = revision + 1,
53
+ updated_at = now()
54
+ WHERE id = 1 AND data->>'releaseVersion' = :'from_version';
55
+ INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
56
+ VALUES (:'to_version', :'plan_id', :'plan_sha', :'from_version');
57
+ COMMIT;
58
+ SQL
59
+
60
+ require_application_release "$URL" "$TO"
61
+ ledger_plan="$(psql_q "$URL" -At -c "SELECT plan_id FROM clearance_schema_migrations WHERE version = '${TO}'")"
62
+ [[ "$ledger_plan" == "$PLAN_ID" ]] || die "migration ledger did not record plan $PLAN_ID"