@celilo/cli 0.10.0 → 0.11.0-alpha.0

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.
@@ -33,6 +33,9 @@ import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
33
33
  import { getModuleSystems } from '../services/deployed-systems';
34
34
  import { withDnsInternalLedger } from '../services/dns-internal-records';
35
35
  import { withDnsRegistrationLedger } from '../services/dns-registrations';
36
+ import { resolveComputedFields } from '../variables/computed/evaluate';
37
+ import { containsComputedMarker } from '../variables/computed/marker';
38
+ import { buildProviderLookup } from '../variables/computed/provider-lookup';
36
39
  import { loadHookConfigMap } from './load-hook-config';
37
40
 
38
41
  /**
@@ -351,6 +354,27 @@ export async function loadCapabilityFunctions(
351
354
  // First registrar wins — multi-registrar setups can't be auto-extended
352
355
  // since we'd have to pick which one to add the new domain to.
353
356
  if (!dnsRegistrarModuleId) dnsRegistrarModuleId = drp.moduleId;
357
+
358
+ // Canonical source: the registrar's DECLARED `domain_list` computed
359
+ // field, resolved LIVE in the provider's context — the exact value
360
+ // `$capability:dns_registrar.domain_list` resolves to and the same set
361
+ // the registrar's own DDNS validation iterates (for namecheap,
362
+ // keys(secret.ddns_passwords)). The manifest calls this "never
363
+ // persisted, never stale"; deriving domains any other way drifts. In
364
+ // particular a leftover `config.domains` row (from an older registrar
365
+ // manifest — module config survives version bumps) would otherwise be
366
+ // preferred by the heuristic below over the live secret keys and
367
+ // silently exclude a just-added domain, dead-ending new-domain
368
+ // onboarding forever (ce-iku).
369
+ const declaredDomains = await resolveRegistrarDomainList(drp.data, drp.moduleId, db);
370
+ if (declaredDomains) {
371
+ dnsManagedDomains.push(...declaredDomains);
372
+ continue;
373
+ }
374
+
375
+ // Fallback for registrars that DON'T declare a `domain_list` computed
376
+ // field (older/archived static-config providers): read the config
377
+ // shape directly.
354
378
  const drConfig = await loadModuleConfig(drp.moduleId, db);
355
379
  // dns_registrar 4.0.0+ exposes a single `domains` array (the
356
380
  // primary_domain/additional_domains split was collapsed — see
@@ -555,6 +579,38 @@ async function loadModuleSecrets(
555
579
  return result;
556
580
  }
557
581
 
582
+ /**
583
+ * Resolve a dns_registrar provider's DECLARED `domain_list` computed field
584
+ * (e.g. namecheap's `keys(secret.ddns_passwords)`) in the PROVIDER's context —
585
+ * the canonical, always-live set of zones it manages. Returns the string list,
586
+ * or null when the provider declares no such computed field so the caller
587
+ * falls back to reading the config shape directly.
588
+ *
589
+ * This is the same value `$capability:dns_registrar.domain_list` resolves to
590
+ * and the same set the registrar's own DDNS validation iterates, so
591
+ * public_web's hostname check can never drift from what the registrar actually
592
+ * manages (ce-iku).
593
+ */
594
+ async function resolveRegistrarDomainList(
595
+ rawData: unknown,
596
+ moduleId: string,
597
+ db: DbClient,
598
+ ): Promise<string[] | null> {
599
+ try {
600
+ const data = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
601
+ if (!containsComputedMarker(data)) return null;
602
+ const lookup = await buildProviderLookup(moduleId, db);
603
+ const resolved = resolveComputedFields(data, lookup) as Record<string, unknown>;
604
+ const list = resolved.domain_list;
605
+ if (!Array.isArray(list)) return null;
606
+ return list.filter((d): d is string => typeof d === 'string' && d.length > 0);
607
+ } catch {
608
+ // Provider context couldn't resolve (e.g. undecryptable secret) — let the
609
+ // caller fall back to the config-shape heuristic.
610
+ return null;
611
+ }
612
+ }
613
+
558
614
  /**
559
615
  * Build a firewall capability chain from multiple providers.
560
616
  *
@@ -0,0 +1,138 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { closeDb } from '@/db/client';
6
+ import {
7
+ getPrincipalByName,
8
+ grantPrincipal,
9
+ grantsAllow,
10
+ isAuthorized,
11
+ listPrincipals,
12
+ renderAuthorizedKeys,
13
+ revokePrincipal,
14
+ validateGrant,
15
+ validatePrincipalName,
16
+ validatePublicKey,
17
+ } from './api-access';
18
+
19
+ const VALID_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@laptop';
20
+
21
+ describe('grantsAllow (pure, deny-by-default)', () => {
22
+ test('exact command:subcommand', () => {
23
+ expect(grantsAllow(['module:deploy'], 'module', 'deploy')).toBe(true);
24
+ expect(grantsAllow(['module:deploy'], 'module', 'remove')).toBe(false);
25
+ });
26
+
27
+ test('command:* wildcard', () => {
28
+ expect(grantsAllow(['service:*'], 'service', 'add')).toBe(true);
29
+ expect(grantsAllow(['service:*'], 'service', 'remove')).toBe(true);
30
+ expect(grantsAllow(['service:*'], 'module', 'deploy')).toBe(false);
31
+ });
32
+
33
+ test('global *', () => {
34
+ expect(grantsAllow(['*'], 'anything', 'goes')).toBe(true);
35
+ });
36
+
37
+ test('bare command for a subcommand-less command', () => {
38
+ expect(grantsAllow(['status'], 'status')).toBe(true);
39
+ expect(grantsAllow(['status'], 'module', 'deploy')).toBe(false);
40
+ });
41
+
42
+ test('empty grants deny everything', () => {
43
+ expect(grantsAllow([], 'module', 'deploy')).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe('validators', () => {
48
+ test('principal name must be kebab-case', () => {
49
+ expect(() => validatePrincipalName('alice')).not.toThrow();
50
+ expect(() => validatePrincipalName('ci-deployer')).not.toThrow();
51
+ expect(() => validatePrincipalName('Alice')).toThrow();
52
+ expect(() => validatePrincipalName('a b')).toThrow();
53
+ expect(() => validatePrincipalName('under_score')).toThrow();
54
+ });
55
+
56
+ test('public key must look like an SSH key', () => {
57
+ expect(() => validatePublicKey(VALID_KEY)).not.toThrow();
58
+ expect(() => validatePublicKey('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5')).not.toThrow();
59
+ expect(() => validatePublicKey('not a key')).toThrow();
60
+ expect(() => validatePublicKey('ssh-ed25519')).toThrow();
61
+ expect(() => validatePublicKey('rsa-bogus AAAA')).toThrow();
62
+ });
63
+
64
+ test('grant syntax', () => {
65
+ expect(() => validateGrant('module:deploy')).not.toThrow();
66
+ expect(() => validateGrant('service:*')).not.toThrow();
67
+ expect(() => validateGrant('*')).not.toThrow();
68
+ expect(() => validateGrant('status')).not.toThrow();
69
+ expect(() => validateGrant('Module:Deploy')).toThrow();
70
+ expect(() => validateGrant('a:b:c')).toThrow();
71
+ });
72
+ });
73
+
74
+ describe('api-access (DB-backed)', () => {
75
+ let dir: string;
76
+
77
+ beforeEach(() => {
78
+ dir = mkdtempSync(join(tmpdir(), 'celilo-api-access-'));
79
+ process.env.CELILO_DB_PATH = join(dir, 'test.db');
80
+ closeDb(); // drop any cached handle so getDb() reopens at the new path
81
+ });
82
+
83
+ afterEach(() => {
84
+ closeDb();
85
+ rmSync(dir, { recursive: true, force: true });
86
+ });
87
+
88
+ test('grant → list → getByName → isAuthorized → render → revoke', async () => {
89
+ const { created } = await grantPrincipal({
90
+ name: 'alice',
91
+ publicKey: VALID_KEY,
92
+ grants: ['module:deploy', 'service:*'],
93
+ });
94
+ expect(created).toBe(true);
95
+
96
+ expect((await listPrincipals()).map((p) => p.name)).toEqual(['alice']);
97
+
98
+ const alice = await getPrincipalByName('alice');
99
+ expect(alice?.grants).toEqual(['module:deploy', 'service:*']);
100
+
101
+ expect(await isAuthorized('alice', 'module', 'deploy')).toBe(true);
102
+ expect(await isAuthorized('alice', 'service', 'add')).toBe(true);
103
+ expect(await isAuthorized('alice', 'secret', 'read')).toBe(false);
104
+ expect(await isAuthorized('nobody', 'module', 'deploy')).toBe(false);
105
+
106
+ const authKeys = await renderAuthorizedKeys();
107
+ expect(authKeys).toContain('command="celilo api-serve --principal=alice"');
108
+ expect(authKeys).toContain('no-pty,no-port-forwarding');
109
+ expect(authKeys).toContain(VALID_KEY);
110
+
111
+ expect(await revokePrincipal('alice')).toBe(true);
112
+ expect(await listPrincipals()).toHaveLength(0);
113
+ expect(await revokePrincipal('alice')).toBe(false);
114
+ expect(await renderAuthorizedKeys()).toBe('');
115
+ });
116
+
117
+ test('grant is an upsert (replaces key + grants)', async () => {
118
+ await grantPrincipal({ name: 'bob', publicKey: VALID_KEY, grants: ['status'] });
119
+ const second = await grantPrincipal({
120
+ name: 'bob',
121
+ publicKey: VALID_KEY,
122
+ grants: ['module:deploy'],
123
+ });
124
+ expect(second.created).toBe(false);
125
+ expect((await getPrincipalByName('bob'))?.grants).toEqual(['module:deploy']);
126
+ expect(await listPrincipals()).toHaveLength(1);
127
+ });
128
+
129
+ test('invalid inputs are rejected before write', async () => {
130
+ await expect(
131
+ grantPrincipal({ name: 'Bad Name', publicKey: VALID_KEY, grants: ['status'] }),
132
+ ).rejects.toThrow();
133
+ await expect(
134
+ grantPrincipal({ name: 'good', publicKey: 'garbage', grants: ['status'] }),
135
+ ).rejects.toThrow();
136
+ expect(await listPrincipals()).toHaveLength(0);
137
+ });
138
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Remote API access control (see v2/API_COMMUNICATION.md, Slice 2a).
3
+ *
4
+ * Stores API principals (name + SSH public key + grants), answers authz
5
+ * queries (deny-by-default), and renders the API account's `authorized_keys`
6
+ * with one forced-command line per principal. No SSH plumbing lives here —
7
+ * `renderAuthorizedKeys()` returns the file content; installing it is a
8
+ * separate operational step (Slice 2b).
9
+ */
10
+
11
+ import { randomUUID } from 'node:crypto';
12
+ import { eq } from 'drizzle-orm';
13
+ import { getDb } from '../db/client';
14
+ import { type ApiPrincipal, apiPrincipals } from '../db/schema';
15
+
16
+ const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
17
+ const GRANT_PATTERN = /^(\*|[a-z0-9]+(-[a-z0-9]+)*(:(\*|[a-z0-9]+(-[a-z0-9]+)*))?)$/;
18
+ const PUBKEY_TYPES = [
19
+ 'ssh-ed25519',
20
+ 'ssh-rsa',
21
+ 'ssh-dss',
22
+ 'ecdsa-sha2-nistp256',
23
+ 'ecdsa-sha2-nistp384',
24
+ 'ecdsa-sha2-nistp521',
25
+ 'sk-ssh-ed25519@openssh.com',
26
+ 'sk-ecdsa-sha2-nistp256@openssh.com',
27
+ ];
28
+
29
+ const FORCED_COMMAND_OPTS = 'no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding';
30
+
31
+ export function validatePrincipalName(name: string): void {
32
+ if (!NAME_PATTERN.test(name)) {
33
+ throw new Error(
34
+ `Invalid principal name "${name}": use kebab-case (e.g. "alice", "ci-deployer").`,
35
+ );
36
+ }
37
+ }
38
+
39
+ /** Basic SSH public-key shape check: `<type> <base64> [comment]`. */
40
+ export function validatePublicKey(key: string): void {
41
+ const parts = key.trim().split(/\s+/);
42
+ if (
43
+ parts.length < 2 ||
44
+ !PUBKEY_TYPES.includes(parts[0]) ||
45
+ !/^[A-Za-z0-9+/]+={0,3}$/.test(parts[1])
46
+ ) {
47
+ throw new Error(
48
+ 'Invalid SSH public key. Expected "<type> <base64> [comment]" (e.g. contents of ~/.ssh/id_ed25519.pub).',
49
+ );
50
+ }
51
+ }
52
+
53
+ export function validateGrant(grant: string): void {
54
+ if (!GRANT_PATTERN.test(grant)) {
55
+ throw new Error(`Invalid grant "${grant}": use "command:subcommand", "command:*", or "*".`);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Does this grant set permit `command`/`subcommand`? Pure — deny-by-default.
61
+ * Matches an exact `command:subcommand`, a `command:*` wildcard, `*`, or a
62
+ * bare `command` grant for a subcommand-less command.
63
+ */
64
+ export function grantsAllow(grants: string[], command: string, subcommand?: string): boolean {
65
+ const op = subcommand ? `${command}:${subcommand}` : command;
66
+ for (const grant of grants) {
67
+ if (grant === '*') return true;
68
+ if (grant === op) return true;
69
+ if (grant === `${command}:*`) return true;
70
+ if (!subcommand && grant === command) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ export async function listPrincipals(): Promise<ApiPrincipal[]> {
76
+ return getDb().select().from(apiPrincipals).all();
77
+ }
78
+
79
+ export async function getPrincipalByName(name: string): Promise<ApiPrincipal | null> {
80
+ const rows = getDb().select().from(apiPrincipals).where(eq(apiPrincipals.name, name)).all();
81
+ return rows[0] ?? null;
82
+ }
83
+
84
+ /**
85
+ * Create or replace a principal's key + grants (upsert by name). Validates all
86
+ * inputs; throws on the first invalid one.
87
+ */
88
+ export async function grantPrincipal(params: {
89
+ name: string;
90
+ publicKey: string;
91
+ grants: string[];
92
+ }): Promise<{ principal: ApiPrincipal; created: boolean }> {
93
+ const { name, publicKey, grants } = params;
94
+ validatePrincipalName(name);
95
+ validatePublicKey(publicKey);
96
+ for (const grant of grants) validateGrant(grant);
97
+
98
+ const db = getDb();
99
+ const existing = await getPrincipalByName(name);
100
+
101
+ if (existing) {
102
+ db.update(apiPrincipals).set({ publicKey, grants }).where(eq(apiPrincipals.name, name)).run();
103
+ const updated = await getPrincipalByName(name);
104
+ if (!updated) throw new Error(`Principal "${name}" vanished after update`);
105
+ return { principal: updated, created: false };
106
+ }
107
+
108
+ const row: ApiPrincipal = {
109
+ id: randomUUID(),
110
+ name,
111
+ publicKey,
112
+ grants,
113
+ createdAt: new Date(),
114
+ updatedAt: new Date(),
115
+ };
116
+ db.insert(apiPrincipals).values(row).run();
117
+ return { principal: row, created: true };
118
+ }
119
+
120
+ /** Remove a principal. Returns false if it didn't exist. */
121
+ export async function revokePrincipal(name: string): Promise<boolean> {
122
+ const existing = await getPrincipalByName(name);
123
+ if (!existing) return false;
124
+ getDb().delete(apiPrincipals).where(eq(apiPrincipals.name, name)).run();
125
+ return true;
126
+ }
127
+
128
+ /** Authz entry point: is `principalName` allowed to run `command`/`subcommand`? */
129
+ export async function isAuthorized(
130
+ principalName: string,
131
+ command: string,
132
+ subcommand?: string,
133
+ ): Promise<boolean> {
134
+ const principal = await getPrincipalByName(principalName);
135
+ if (!principal) return false;
136
+ return grantsAllow(principal.grants, command, subcommand);
137
+ }
138
+
139
+ /**
140
+ * Render the API account's `authorized_keys` — one forced-command line per
141
+ * principal. The principal name is baked into the command so sshd hands the
142
+ * identity to `api-serve` (see the design doc). Names are kebab-case-validated,
143
+ * so no shell-escaping is required inside the `command=` string.
144
+ */
145
+ export async function renderAuthorizedKeys(): Promise<string> {
146
+ const principals = await listPrincipals();
147
+ if (principals.length === 0) return '';
148
+ return `${principals
149
+ .map(
150
+ (p) =>
151
+ `command="celilo api-serve --principal=${p.name}",${FORCED_COMMAND_OPTS} ${p.publicKey}`,
152
+ )
153
+ .join('\n')}\n`;
154
+ }
@@ -0,0 +1,78 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { type BusEvent, defineEvents, openBus } from '@celilo/event-bus';
6
+ import { type WireInterview, startRemoteResponder } from './remote-responder';
7
+
8
+ const NO_SCHEMAS = defineEvents({});
9
+
10
+ let dir: string;
11
+ let busDbPath: string;
12
+
13
+ beforeEach(() => {
14
+ dir = mkdtempSync(join(tmpdir(), 'celilo-remresp-'));
15
+ busDbPath = join(dir, 'events.db');
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ });
21
+
22
+ test('forwards an interview.required question to ask and replies with the answer', async () => {
23
+ const asked: WireInterview[] = [];
24
+ const responder = startRemoteResponder({
25
+ busDbPath,
26
+ ask: async (iv) => {
27
+ asked.push(iv);
28
+ return 'acme.example.com';
29
+ },
30
+ });
31
+
32
+ const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
33
+ try {
34
+ const replies = (await bus.query(
35
+ 'interview.required.site.hostname' as never,
36
+ {
37
+ scope: 'site',
38
+ key: 'hostname',
39
+ kind: 'text',
40
+ message: 'Hostname for the site?',
41
+ required: true,
42
+ } as never,
43
+ { timeoutMs: 8000, pollIntervalMs: 100, expect: 'first' } as never,
44
+ )) as BusEvent[];
45
+
46
+ expect(replies).toHaveLength(1);
47
+ expect((replies[0].payload as { value: unknown }).value).toBe('acme.example.com');
48
+ expect(asked).toHaveLength(1);
49
+ expect(asked[0].message).toBe('Hostname for the site?');
50
+ expect(asked[0].kind).toBe('text');
51
+ } finally {
52
+ bus.close();
53
+ responder.close();
54
+ }
55
+ }, 15_000);
56
+
57
+ test('answers responder.probe with kind daemon', async () => {
58
+ const responder = startRemoteResponder({ busDbPath, ask: async () => 'unused' });
59
+
60
+ const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
61
+ try {
62
+ const replies = (await bus.query(
63
+ 'responder.probe' as never,
64
+ {} as never,
65
+ {
66
+ timeoutMs: 8000,
67
+ pollIntervalMs: 100,
68
+ expect: 'first',
69
+ } as never,
70
+ )) as BusEvent[];
71
+
72
+ expect(replies).toHaveLength(1);
73
+ expect((replies[0].payload as { kind: string }).kind).toBe('daemon');
74
+ } finally {
75
+ bus.close();
76
+ responder.close();
77
+ }
78
+ }, 15_000);
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Remote bus responder (`kind: 'daemon'`) — Slice 3.
3
+ *
4
+ * Runs in the `api-serve` parent and bridges the event bus to the wire: watches
5
+ * the generic `interview.required.*` family, forwards each question to the
6
+ * remote client via an injected `ask` round-trip, and replies on the bus with
7
+ * the client's answer. Also answers `responder.probe` so the (non-TTY) command
8
+ * child doesn't fail-fast for lack of a responder.
9
+ *
10
+ * The deploy-specific families (config / secret / ensure / aspect) reuse this
11
+ * same plumbing with per-family adapters — tracked as a follow-up.
12
+ */
13
+
14
+ import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
15
+ import type { InterviewRequiredPayload } from './bus-interview';
16
+ import { RESPONDER_PROBE_EVENT } from './responder-probe';
17
+
18
+ const NO_SCHEMAS = defineEvents({});
19
+
20
+ /** The normalized question handed to the wire (mirrors InterviewMessage). */
21
+ export interface WireInterview {
22
+ id: string;
23
+ kind: InterviewRequiredPayload['kind'];
24
+ message: string;
25
+ description?: string;
26
+ defaultValue?: string;
27
+ placeholder?: string;
28
+ options?: Array<{ value: string; label: string; hint?: string }>;
29
+ required?: boolean;
30
+ }
31
+
32
+ export interface RemoteResponderOptions {
33
+ /** Path to the shared bus sqlite db (command child + responder share it). */
34
+ busDbPath: string;
35
+ /** Forward a question to the client and resolve with its answer. */
36
+ ask: (interview: WireInterview) => Promise<unknown>;
37
+ /** `emittedBy` audit label on replies. Defaults to `daemon`. */
38
+ emittedBy?: string;
39
+ }
40
+
41
+ export interface RemoteResponderHandle {
42
+ close(): void;
43
+ }
44
+
45
+ export function startRemoteResponder(opts: RemoteResponderOptions): RemoteResponderHandle {
46
+ const me = opts.emittedBy ?? 'daemon';
47
+ const bus: Bus = openBus({ dbPath: opts.busDbPath, events: NO_SCHEMAS });
48
+
49
+ const interviewWatch = bus.watch('interview.required.*.*', async (event) => {
50
+ if (event.replyFor !== null) return;
51
+
52
+ const payload = event.payload as InterviewRequiredPayload;
53
+ if (!payload || typeof payload.scope !== 'string' || typeof payload.key !== 'string') {
54
+ return;
55
+ }
56
+
57
+ const value = await opts.ask({
58
+ id: String(event.id),
59
+ kind: payload.kind,
60
+ message: payload.message,
61
+ description: payload.description,
62
+ defaultValue: payload.defaultValue,
63
+ placeholder: payload.placeholder,
64
+ options: payload.options,
65
+ required: payload.required,
66
+ });
67
+
68
+ bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me });
69
+ });
70
+
71
+ // Liveness probe: the non-TTY command child emits `responder.probe` before
72
+ // asking, to confirm someone is listening. Answer with our kind.
73
+ const probeWatch = bus.watch(RESPONDER_PROBE_EVENT, async (event) => {
74
+ if (event.replyFor !== null) return;
75
+ bus.emitRaw(
76
+ `${event.type}.reply`,
77
+ { kind: 'daemon', emittedBy: me },
78
+ { replyFor: event.id, emittedBy: me },
79
+ );
80
+ });
81
+
82
+ return {
83
+ close: () => {
84
+ interviewWatch.close();
85
+ probeWatch.close();
86
+ bus.close();
87
+ },
88
+ };
89
+ }