@rizom/ops 0.2.0-alpha.12 → 0.2.0-alpha.14

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.
@@ -13,6 +13,8 @@ export interface ResolvedCohort {
13
13
  brainVersionOverride?: string;
14
14
  presetOverride?: PilotPreset;
15
15
  aiApiKeyOverride?: string;
16
+ gitSyncTokenOverride?: string;
17
+ mcpAuthTokenOverride?: string;
16
18
  }
17
19
  export interface ResolvedAnchorProfileSocialLink {
18
20
  platform: "github" | "instagram" | "linkedin" | "email" | "website";
@@ -36,7 +38,10 @@ export interface ResolvedUserIdentity {
36
38
  domain: string;
37
39
  contentRepo: string;
38
40
  discordEnabled: boolean;
41
+ discordAnchorUserId?: string;
39
42
  effectiveAiApiKey: string;
43
+ effectiveGitSyncToken: string;
44
+ effectiveMcpAuthToken: string;
40
45
  anchorProfile: ResolvedAnchorProfile;
41
46
  snapshotStatus: SnapshotStatus;
42
47
  }
@@ -54,7 +59,4 @@ export interface PilotRegistry {
54
59
  cohorts: ResolvedCohort[];
55
60
  users: ResolvedUser[];
56
61
  }
57
- declare class PilotRegistryError extends Error {
58
- }
59
62
  export declare function loadPilotRegistry(rootDir: string, options?: LoadPilotRegistryOptions): Promise<PilotRegistry>;
60
- export { PilotRegistryError };
@@ -5,5 +5,5 @@ export interface PushSecretsOptions {
5
5
  runCommand?: RunCommand | undefined;
6
6
  logger?: ((message: string) => void) | undefined;
7
7
  }
8
- export declare function pushSecretsToBackend(target: PushTarget, secrets: readonly SecretPair[], options?: PushSecretsOptions): Promise<void>;
8
+ export declare function pushSecretsToBackend(_target: PushTarget, secrets: readonly SecretPair[], options?: PushSecretsOptions): Promise<void>;
9
9
  export { normalizePushTarget };
@@ -15,7 +15,6 @@ export interface CommandDependencies extends LoadPilotRegistryOptions {
15
15
  logger?: ((message: string) => void) | undefined;
16
16
  fetchImpl?: FetchLike | undefined;
17
17
  lookupHost?: LookupHost | undefined;
18
- secretRunCommand?: OpsRunCommand | undefined;
19
18
  bootstrapRunCommand?: OpsRunCommand | undefined;
20
19
  sshKeygen?: SshKeygen | undefined;
21
20
  }
package/dist/schema.d.ts CHANGED
@@ -3,6 +3,7 @@ export declare const presetSchema: z.ZodEnum<["core", "default", "pro"]>;
3
3
  export declare const exactVersionSchema: z.ZodString;
4
4
  export declare const handleSchema: z.ZodString;
5
5
  export declare const secretNameSchema: z.ZodString;
6
+ export declare const agePublicKeySchema: z.ZodString;
6
7
  export declare const pilotSchema: z.ZodObject<{
7
8
  schemaVersion: z.ZodLiteral<1>;
8
9
  brainVersion: z.ZodString;
@@ -12,7 +13,11 @@ export declare const pilotSchema: z.ZodObject<{
12
13
  domainSuffix: z.ZodString;
13
14
  preset: z.ZodEnum<["core", "default", "pro"]>;
14
15
  aiApiKey: z.ZodString;
16
+ gitSyncToken: z.ZodString;
17
+ mcpAuthToken: z.ZodString;
18
+ agePublicKey: z.ZodString;
15
19
  }, "strict", z.ZodTypeAny, {
20
+ agePublicKey: string;
16
21
  schemaVersion: 1;
17
22
  brainVersion: string;
18
23
  model: "rover";
@@ -21,7 +26,10 @@ export declare const pilotSchema: z.ZodObject<{
21
26
  domainSuffix: string;
22
27
  preset: "default" | "core" | "pro";
23
28
  aiApiKey: string;
29
+ gitSyncToken: string;
30
+ mcpAuthToken: string;
24
31
  }, {
32
+ agePublicKey: string;
25
33
  schemaVersion: 1;
26
34
  brainVersion: string;
27
35
  model: "rover";
@@ -30,17 +38,24 @@ export declare const pilotSchema: z.ZodObject<{
30
38
  domainSuffix: string;
31
39
  preset: "default" | "core" | "pro";
32
40
  aiApiKey: string;
41
+ gitSyncToken: string;
42
+ mcpAuthToken: string;
33
43
  }>;
34
44
  export declare const userSchema: z.ZodObject<{
35
45
  handle: z.ZodString;
36
46
  discord: z.ZodObject<{
37
47
  enabled: z.ZodBoolean;
48
+ anchorUserId: z.ZodOptional<z.ZodString>;
38
49
  }, "strict", z.ZodTypeAny, {
39
50
  enabled: boolean;
51
+ anchorUserId?: string | undefined;
40
52
  }, {
41
53
  enabled: boolean;
54
+ anchorUserId?: string | undefined;
42
55
  }>;
43
56
  aiApiKeyOverride: z.ZodOptional<z.ZodString>;
57
+ gitSyncTokenOverride: z.ZodOptional<z.ZodString>;
58
+ mcpAuthTokenOverride: z.ZodOptional<z.ZodString>;
44
59
  anchorProfile: z.ZodOptional<z.ZodObject<{
45
60
  name: z.ZodOptional<z.ZodString>;
46
61
  description: z.ZodOptional<z.ZodString>;
@@ -61,9 +76,9 @@ export declare const userSchema: z.ZodObject<{
61
76
  label?: string | undefined;
62
77
  }>, "many">>;
63
78
  }, "strict", z.ZodTypeAny, {
79
+ name?: string | undefined;
64
80
  email?: string | undefined;
65
81
  website?: string | undefined;
66
- name?: string | undefined;
67
82
  description?: string | undefined;
68
83
  story?: string | undefined;
69
84
  socialLinks?: {
@@ -72,9 +87,9 @@ export declare const userSchema: z.ZodObject<{
72
87
  label?: string | undefined;
73
88
  }[] | undefined;
74
89
  }, {
90
+ name?: string | undefined;
75
91
  email?: string | undefined;
76
92
  website?: string | undefined;
77
- name?: string | undefined;
78
93
  description?: string | undefined;
79
94
  story?: string | undefined;
80
95
  socialLinks?: {
@@ -87,12 +102,15 @@ export declare const userSchema: z.ZodObject<{
87
102
  handle: string;
88
103
  discord: {
89
104
  enabled: boolean;
105
+ anchorUserId?: string | undefined;
90
106
  };
91
107
  aiApiKeyOverride?: string | undefined;
108
+ gitSyncTokenOverride?: string | undefined;
109
+ mcpAuthTokenOverride?: string | undefined;
92
110
  anchorProfile?: {
111
+ name?: string | undefined;
93
112
  email?: string | undefined;
94
113
  website?: string | undefined;
95
- name?: string | undefined;
96
114
  description?: string | undefined;
97
115
  story?: string | undefined;
98
116
  socialLinks?: {
@@ -105,12 +123,15 @@ export declare const userSchema: z.ZodObject<{
105
123
  handle: string;
106
124
  discord: {
107
125
  enabled: boolean;
126
+ anchorUserId?: string | undefined;
108
127
  };
109
128
  aiApiKeyOverride?: string | undefined;
129
+ gitSyncTokenOverride?: string | undefined;
130
+ mcpAuthTokenOverride?: string | undefined;
110
131
  anchorProfile?: {
132
+ name?: string | undefined;
111
133
  email?: string | undefined;
112
134
  website?: string | undefined;
113
- name?: string | undefined;
114
135
  description?: string | undefined;
115
136
  story?: string | undefined;
116
137
  socialLinks?: {
@@ -125,24 +146,34 @@ export declare const cohortSchema: z.ZodEffects<z.ZodObject<{
125
146
  brainVersionOverride: z.ZodOptional<z.ZodString>;
126
147
  presetOverride: z.ZodOptional<z.ZodEnum<["core", "default", "pro"]>>;
127
148
  aiApiKeyOverride: z.ZodOptional<z.ZodString>;
149
+ gitSyncTokenOverride: z.ZodOptional<z.ZodString>;
150
+ mcpAuthTokenOverride: z.ZodOptional<z.ZodString>;
128
151
  }, "strict", z.ZodTypeAny, {
129
152
  members: string[];
130
153
  aiApiKeyOverride?: string | undefined;
154
+ gitSyncTokenOverride?: string | undefined;
155
+ mcpAuthTokenOverride?: string | undefined;
131
156
  brainVersionOverride?: string | undefined;
132
157
  presetOverride?: "default" | "core" | "pro" | undefined;
133
158
  }, {
134
159
  members: string[];
135
160
  aiApiKeyOverride?: string | undefined;
161
+ gitSyncTokenOverride?: string | undefined;
162
+ mcpAuthTokenOverride?: string | undefined;
136
163
  brainVersionOverride?: string | undefined;
137
164
  presetOverride?: "default" | "core" | "pro" | undefined;
138
165
  }>, {
139
166
  members: string[];
140
167
  aiApiKeyOverride?: string | undefined;
168
+ gitSyncTokenOverride?: string | undefined;
169
+ mcpAuthTokenOverride?: string | undefined;
141
170
  brainVersionOverride?: string | undefined;
142
171
  presetOverride?: "default" | "core" | "pro" | undefined;
143
172
  }, {
144
173
  members: string[];
145
174
  aiApiKeyOverride?: string | undefined;
175
+ gitSyncTokenOverride?: string | undefined;
176
+ mcpAuthTokenOverride?: string | undefined;
146
177
  brainVersionOverride?: string | undefined;
147
178
  presetOverride?: "default" | "core" | "pro" | undefined;
148
179
  }>;
@@ -0,0 +1,32 @@
1
+ import { z } from "@brains/utils";
2
+ declare const encryptedUserSecretsSchema: z.ZodObject<{
3
+ gitSyncToken: z.ZodOptional<z.ZodString>;
4
+ mcpAuthToken: z.ZodOptional<z.ZodString>;
5
+ discordBotToken: z.ZodOptional<z.ZodString>;
6
+ aiApiKey: z.ZodOptional<z.ZodString>;
7
+ }, "strict", z.ZodTypeAny, {
8
+ aiApiKey?: string | undefined;
9
+ gitSyncToken?: string | undefined;
10
+ mcpAuthToken?: string | undefined;
11
+ discordBotToken?: string | undefined;
12
+ }, {
13
+ aiApiKey?: string | undefined;
14
+ gitSyncToken?: string | undefined;
15
+ mcpAuthToken?: string | undefined;
16
+ discordBotToken?: string | undefined;
17
+ }>;
18
+ export type EncryptedUserSecrets = z.infer<typeof encryptedUserSecretsSchema>;
19
+ export interface SecretsEncryptOptions {
20
+ env?: NodeJS.ProcessEnv | undefined;
21
+ logger?: ((message: string) => void) | undefined;
22
+ dryRun?: boolean | undefined;
23
+ }
24
+ export interface SecretsEncryptResult {
25
+ encryptedPath: string;
26
+ plaintextPath: string;
27
+ deletedPlaintext: boolean;
28
+ encryptedKeys: Array<keyof EncryptedUserSecrets>;
29
+ dryRun?: boolean | undefined;
30
+ }
31
+ export declare function encryptPilotSecrets(rootDir: string, handle: string, options?: SecretsEncryptOptions): Promise<SecretsEncryptResult>;
32
+ export {};
@@ -10,4 +10,4 @@ export interface SecretsPushResult {
10
10
  skippedKeys: string[];
11
11
  dryRun?: boolean | undefined;
12
12
  }
13
- export declare function pushPilotSecrets(rootDir: string, handle: string, options?: SecretsPushOptions): Promise<SecretsPushResult>;
13
+ export declare function pushPilotSecrets(rootDir: string, options?: SecretsPushOptions): Promise<SecretsPushResult>;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.0-alpha.12",
7
+ "version": "0.2.0-alpha.14",
8
8
  "type": "module",
9
9
  "exports": {
10
10
  ".": {
@@ -32,9 +32,11 @@
32
32
  "typecheck": "tsc --noEmit",
33
33
  "lint": "eslint . --ext .ts",
34
34
  "lint:fix": "eslint . --ext .ts --fix",
35
- "test": "bun test"
35
+ "test": "bun test --timeout 20000"
36
+ },
37
+ "dependencies": {
38
+ "age-encryption": "^0.3.0"
36
39
  },
37
- "dependencies": {},
38
40
  "devDependencies": {
39
41
  "@brains/eslint-config": "workspace:*",
40
42
  "@brains/typescript-config": "workspace:*",
@@ -4,18 +4,23 @@
4
4
  # ----------
5
5
 
6
6
  # AI provider
7
+ # Shared GitHub secret by default; a per-user override may come from the decrypted
8
+ # users/<handle>.secrets.yaml.age file at deploy time.
7
9
  # @required @sensitive
8
10
  AI_API_KEY=
9
11
 
10
12
  # Git sync
13
+ # Comes from the decrypted users/<handle>.secrets.yaml.age file.
11
14
  # @required @sensitive
12
15
  GIT_SYNC_TOKEN=
13
16
 
14
17
  # MCP interface
18
+ # Comes from the decrypted users/<handle>.secrets.yaml.age file.
15
19
  # @required @sensitive
16
20
  MCP_AUTH_TOKEN=
17
21
 
18
22
  # Discord (optional, per-user)
23
+ # Comes from the decrypted users/<handle>.secrets.yaml.age file when enabled.
19
24
  # @sensitive
20
25
  DISCORD_BOT_TOKEN=
21
26
 
@@ -13,6 +13,7 @@ on:
13
13
  - users/*/.env
14
14
  - users/*/brain.yaml
15
15
  - users/*/content/**
16
+ - users/*.secrets.yaml.age
16
17
  - deploy/**
17
18
  - .github/workflows/deploy.yml
18
19
 
@@ -74,16 +75,18 @@ jobs:
74
75
  - name: Install operator tooling
75
76
  run: bun install
76
77
 
77
- - name: Resolve selected user secret names
78
- id: user_secret_names
79
- run: |
80
- HANDLE_SUFFIX="$(printf '%s' "$HANDLE" | tr '[:lower:]-' '[:upper:]_')"
81
- echo "git_sync_token_secret_name=GIT_SYNC_TOKEN_${HANDLE_SUFFIX}" >> "$GITHUB_OUTPUT"
78
+ - name: Decrypt user secrets
79
+ id: user_secrets
80
+ env:
81
+ AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
82
+ run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
82
83
 
83
84
  - name: Reconcile selected user config
84
85
  env:
85
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_secret_names.outputs.git_sync_token_secret_name] }}
86
- run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
86
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
87
+ run: |
88
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
89
+ bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
87
90
 
88
91
  - name: Resolve generated user config
89
92
  id: user_config
@@ -91,10 +94,9 @@ jobs:
91
94
 
92
95
  - name: Validate selected secrets
93
96
  env:
94
- AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
95
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
96
- MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
97
- DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
97
+ SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
98
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
99
+ SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
98
100
  HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
99
101
  HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
100
102
  HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
@@ -105,13 +107,19 @@ jobs:
105
107
  CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
106
108
  CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
107
109
  PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
108
- run: bun deploy/scripts/validate-secrets.ts
110
+ run: |
111
+ export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
112
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
113
+ export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
114
+ bun deploy/scripts/validate-secrets.ts
109
115
 
110
116
  - name: Seed content repo
111
117
  env:
112
118
  CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
113
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
114
- run: bun deploy/scripts/sync-content-repo.ts
119
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
120
+ run: |
121
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
122
+ bun deploy/scripts/sync-content-repo.ts
115
123
 
116
124
  - name: Log in to GHCR
117
125
  uses: docker/login-action@v3
@@ -158,14 +166,17 @@ jobs:
158
166
 
159
167
  - name: Write .kamal/secrets
160
168
  env:
161
- AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
162
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
163
- MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
164
- DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
169
+ SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
170
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
171
+ SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
165
172
  KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
166
173
  CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
167
174
  PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
168
- run: bun deploy/scripts/write-kamal-secrets.ts
175
+ run: |
176
+ export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
177
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
178
+ export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
179
+ bun deploy/scripts/write-kamal-secrets.ts
169
180
 
170
181
  - name: Provision server
171
182
  id: provision
@@ -182,10 +193,11 @@ jobs:
182
193
  CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
183
194
  CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
184
195
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
196
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
185
197
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
186
198
  run: |
187
199
  bun deploy/scripts/update-dns.ts
188
- BRAIN_DOMAIN="preview.$BRAIN_DOMAIN" bun deploy/scripts/update-dns.ts
200
+ BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
189
201
 
190
202
  - name: Validate SSH key
191
203
  run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
@@ -212,6 +224,7 @@ jobs:
212
224
  IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
213
225
  REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
214
226
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
227
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
215
228
  BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
216
229
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
217
230
 
@@ -219,7 +232,10 @@ jobs:
219
232
  env:
220
233
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
221
234
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
222
- run: curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
235
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
236
+ run: |
237
+ curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
238
+ curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
223
239
 
224
240
  - name: Upload generated config
225
241
  uses: actions/upload-artifact@v4
@@ -38,8 +38,9 @@ When a push changes only deploy contract files, CI prints `No affected user conf
38
38
  - `brains-ops init <repo>`
39
39
  - `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health`, and unauthenticated `/mcp` status checks
40
40
  - `brains-ops onboard <repo> <handle>`
41
+ - `brains-ops age-key:bootstrap <repo>`
41
42
  - `brains-ops ssh-key:bootstrap <repo>`
42
- - `brains-ops cert:bootstrap <repo> <handle>`
43
- - `brains-ops secrets:push <repo> <handle>`
43
+ - `brains-ops cert:bootstrap <repo>`
44
+ - `brains-ops secrets:encrypt <repo> <handle>`
44
45
  - `brains-ops reconcile-cohort <repo> <cohort>`
45
46
  - `brains-ops reconcile-all <repo>`
@@ -1,7 +1,7 @@
1
1
  # Internal Caddy — path-based routing to brain services.
2
2
  # kamal-proxy terminates TLS externally; this runs inside the container.
3
3
  :80 {
4
- @preview host preview.*
4
+ @preview host *-preview.*
5
5
  handle @preview {
6
6
  reverse_proxy localhost:4321
7
7
 
@@ -12,7 +12,7 @@ proxy:
12
12
  private_key_pem: PRIVATE_KEY_PEM
13
13
  hosts:
14
14
  - <%= ENV['BRAIN_DOMAIN'] %>
15
- - preview.<%= ENV['BRAIN_DOMAIN'] %>
15
+ - <%= ENV['PREVIEW_DOMAIN'] %>
16
16
  app_port: 80
17
17
  healthcheck:
18
18
  path: /health
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { Decrypter, armor } from "age-encryption";
4
+
5
+ import { requireEnv, writeGitHubEnv, writeGitHubOutput } from "./helpers";
6
+
7
+ const handle = process.argv[2] ?? requireEnv("HANDLE");
8
+ const ageSecretKey = extractAgeIdentity(requireEnv("AGE_SECRET_KEY"));
9
+ const encryptedPath = `users/${handle}.secrets.yaml.age`;
10
+
11
+ const armored = readFileSync(encryptedPath, "utf8");
12
+ const decoded = armor.decode(armored);
13
+
14
+ const decrypter = new Decrypter();
15
+ decrypter.addIdentity(ageSecretKey);
16
+
17
+ const plaintext = await decrypter.decrypt(decoded, "text");
18
+ const secrets = parseFlatYaml(plaintext);
19
+ const pilot = parseFlatYaml(readFileSync("pilot.yaml", "utf8"));
20
+
21
+ writeGitHubEnv("AI_API_KEY", secrets["aiApiKey"] ?? "");
22
+ writeGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"] ?? "");
23
+ writeGitHubEnv("MCP_AUTH_TOKEN", secrets["mcpAuthToken"] ?? "");
24
+ writeGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"] ?? "");
25
+
26
+ writeGitHubOutput(
27
+ "shared_ai_api_key_secret_name",
28
+ requireFlatValue(pilot, "aiApiKey", "pilot.yaml"),
29
+ );
30
+ writeGitHubOutput(
31
+ "shared_git_sync_token_secret_name",
32
+ requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
33
+ );
34
+ writeGitHubOutput(
35
+ "shared_mcp_auth_token_secret_name",
36
+ requireFlatValue(pilot, "mcpAuthToken", "pilot.yaml"),
37
+ );
38
+
39
+ function extractAgeIdentity(contents: string): string {
40
+ const line = contents
41
+ .split(/\r?\n/)
42
+ .map((entry) => entry.trim())
43
+ .find((entry) => entry.startsWith("AGE-SECRET-KEY-"));
44
+
45
+ if (!line) {
46
+ throw new Error("Missing AGE-SECRET-KEY in AGE_SECRET_KEY");
47
+ }
48
+
49
+ return line;
50
+ }
51
+
52
+ function parseFlatYaml(contents: string): Record<string, string> {
53
+ const result: Record<string, string> = {};
54
+
55
+ for (const rawLine of contents.split(/\r?\n/)) {
56
+ const line = rawLine.trim();
57
+ if (!line || line.startsWith("#")) {
58
+ continue;
59
+ }
60
+
61
+ const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
62
+ if (!match) {
63
+ continue;
64
+ }
65
+
66
+ const [, key, rawValue] = match;
67
+ result[key] = rawValue.replace(/^['"]|['"]$/g, "");
68
+ }
69
+
70
+ return result;
71
+ }
72
+
73
+ function requireFlatValue(
74
+ values: Record<string, string>,
75
+ key: string,
76
+ label: string,
77
+ ): string {
78
+ const value = values[key];
79
+ if (!value) {
80
+ throw new Error(`Missing ${key} in ${label}`);
81
+ }
82
+ return value;
83
+ }
@@ -101,7 +101,7 @@ while (server.status !== "running" || !server.public_net?.ipv4?.ip) {
101
101
  server = await getServer(server.id);
102
102
  }
103
103
 
104
- const serverIp = server.public_net?.ipv4?.ip;
104
+ const serverIp = server.public_net.ipv4.ip;
105
105
  if (!serverIp) {
106
106
  throw new Error(`Server ${server.id} running but has no IPv4 address`);
107
107
  }
@@ -32,9 +32,9 @@ const handles = [
32
32
  diffOutput
33
33
  .split(/\r?\n/)
34
34
  .map((path) => {
35
- const match = path.match(
36
- /^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/,
37
- );
35
+ const match =
36
+ path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/) ??
37
+ path.match(/^users\/([^/]+)\.secrets\.yaml\.age$/);
38
38
  return match?.[1] ?? null;
39
39
  })
40
40
  .filter((handle): handle is string => handle !== null)
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
+
2
3
  import { parseEnvFile, requireEnv, writeGitHubOutput } from "./helpers";
3
4
 
4
5
  const handle = requireEnv("HANDLE");
@@ -12,32 +13,31 @@ const repositoryOwner = repository.split("/")[0] ?? "";
12
13
  const brainYaml = readFileSync(brainYamlPath, "utf8");
13
14
  const domainMatch = brainYaml.match(/^domain:\s*(.+)$/m);
14
15
  const brainDomain = domainMatch?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
15
-
16
16
  if (!brainDomain) {
17
17
  throw new Error(`Missing domain in ${brainYamlPath}`);
18
18
  }
19
19
 
20
+ const zone =
21
+ brainDomain.startsWith(`${handle}.`) && brainDomain.length > handle.length + 1
22
+ ? brainDomain.slice(handle.length + 1)
23
+ : "";
24
+ if (!zone) {
25
+ throw new Error(`Could not derive preview domain from ${brainDomain}`);
26
+ }
27
+ const previewDomain = `${handle}-preview.${zone}`;
28
+
20
29
  const outputs: Record<string, string> = {
21
30
  brain_version: envEntries["BRAIN_VERSION"] ?? "",
22
- ai_api_key_secret_name: envEntries["AI_API_KEY_SECRET"] ?? "",
23
- git_sync_token_secret_name: envEntries["GIT_SYNC_TOKEN_SECRET"] ?? "",
24
- mcp_auth_token_secret_name: envEntries["MCP_AUTH_TOKEN_SECRET"] ?? "",
25
- discord_bot_token_secret_name: envEntries["DISCORD_BOT_TOKEN_SECRET"] ?? "",
26
31
  content_repo: envEntries["CONTENT_REPO"] ?? "",
27
32
  brain_domain: brainDomain,
33
+ preview_domain: previewDomain,
28
34
  brain_yaml_path: brainYamlPath,
29
35
  instance_name: `rover-${handle}`,
30
36
  image_repository: `ghcr.io/${repository}`,
31
37
  registry_username: repositoryOwner,
32
38
  };
33
39
 
34
- const required = [
35
- "brain_version",
36
- "ai_api_key_secret_name",
37
- "git_sync_token_secret_name",
38
- "mcp_auth_token_secret_name",
39
- "registry_username",
40
- ];
40
+ const required = ["brain_version", "registry_username"];
41
41
  for (const key of required) {
42
42
  if (!outputs[key]) {
43
43
  throw new Error(`Missing ${key} (derived from ${envPath})`);
@@ -1,17 +1,26 @@
1
1
  # Onboarding Checklist
2
2
 
3
3
  1. Run `bun install` so the repo uses its pinned `@rizom/ops` version.
4
- 2. Fill in `pilot.yaml`.
5
- 3. Add or edit `users/<handle>.yaml`.
6
- 4. Add the user to a cohort in `cohorts/*.yaml`.
7
- 5. Run `bunx brains-ops render <repo>`.
8
- 6. Run `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`.
9
- 7. Run `bunx brains-ops cert:bootstrap <repo> <handle> --push-to gh`.
10
- 8. Run `bunx brains-ops secrets:push <repo> <handle>`.
11
- 9. Run `bunx brains-ops onboard <repo> <handle>`.
12
- 10. Verify the deployed rover core contract:
4
+ 2. Run `bunx brains-ops age-key:bootstrap <repo> --push-to gh`.
5
+ 3. Fill in `pilot.yaml`.
6
+ - keep your pinned `brainVersion`
7
+ - confirm shared selectors for `aiApiKey`, `gitSyncToken`, and `mcpAuthToken`
8
+ - confirm `agePublicKey`
9
+ 4. Add or edit `users/<handle>.yaml`.
10
+ - Discord is enabled by default for pilot users
11
+ - if the user should be an anchor there, set `discord.anchorUserId` to their Discord user ID
12
+ 5. Add the user to a cohort in `cohorts/*.yaml`.
13
+ 6. Run `bunx brains-ops render <repo>`.
14
+ 7. Run `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`.
15
+ 8. Run `bunx brains-ops cert:bootstrap <repo> --push-to gh`.
16
+ 9. Keep raw user secret material locally for now (`.env.local`, file-backed env vars, or equivalent local inputs).
17
+ 10. Run `bunx brains-ops secrets:encrypt <repo> <handle>`.
18
+ 11. Commit and push `users/<handle>.secrets.yaml.age`.
19
+ 12. Run `bunx brains-ops onboard <repo> <handle>`.
20
+ 13. Verify the deployed rover core contract:
13
21
  - `https://<handle>.rizom.ai/health` returns `200`
14
22
  - unauthenticated `POST https://<handle>.rizom.ai/mcp` returns `401`
15
- 11. For fleet upgrades, edit `pilot.yaml.brainVersion` and push once; CI rebuilds the shared image tag, refreshes generated user env files, and redeploys affected users.
16
- 12. Hand the MCP connection details to the user.
17
- 13. Send `docs/user-onboarding.md` to the user as the pilot handoff guide.
23
+ 14. For fleet upgrades, edit `pilot.yaml.brainVersion` and push once; CI rebuilds the shared image tag, refreshes generated user env files, and redeploys affected users.
24
+ 15. Hand the Discord setup details to the user. If they need direct client access, also hand over the MCP connection details.
25
+ 16. If you are also giving them a content repo workflow, describe it first as a normal git repo of markdown/text files; mention Obsidian only as an optional editor.
26
+ 17. Send `docs/user-onboarding.md` to the user as the pilot handoff guide.