@celilo/cli 0.9.1 → 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.
Files changed (34) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +16 -0
  3. package/drizzle/0014_api_principals.sql +10 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +14 -6
  6. package/src/api/protocol.test.ts +76 -0
  7. package/src/api/remote-client.test.ts +91 -0
  8. package/src/api/serve.ts +159 -0
  9. package/src/cli/command-tree-parser.ts +3 -1
  10. package/src/cli/commands/api.ts +194 -0
  11. package/src/cli/commands/apt-upgrade.test.ts +33 -0
  12. package/src/cli/commands/apt-upgrade.ts +63 -0
  13. package/src/cli/commands/commands-json.ts +29 -0
  14. package/src/cli/commands/completion.ts +1 -1
  15. package/src/cli/commands/module-list.ts +16 -2
  16. package/src/cli/commands/publish/helpers.ts +18 -0
  17. package/src/cli/commands/publish/types.ts +6 -8
  18. package/src/cli/commands/publish/workspace.test.ts +44 -7
  19. package/src/cli/commands/publish/workspace.ts +40 -164
  20. package/src/cli/commands/service-list.ts +15 -2
  21. package/src/cli/completion.ts +24 -0
  22. package/src/cli/generate-zsh-completion.test.ts +22 -4
  23. package/src/cli/generate-zsh-completion.ts +7 -3
  24. package/src/cli/index.ts +109 -2
  25. package/src/cli/parser.test.ts +13 -0
  26. package/src/cli/parser.ts +12 -3
  27. package/src/db/schema.ts +30 -0
  28. package/src/hooks/capability-loader.test.ts +77 -0
  29. package/src/hooks/capability-loader.ts +56 -0
  30. package/src/services/api-access.test.ts +138 -0
  31. package/src/services/api-access.ts +154 -0
  32. package/src/services/remote-responder.test.ts +78 -0
  33. package/src/services/remote-responder.ts +89 -0
  34. package/src/cli/command-registry.ts +0 -1443
@@ -9,14 +9,18 @@
9
9
  * should never be hand-edited.
10
10
  */
11
11
 
12
- import type { ArgDef, CommandDef, FlagDef } from './command-registry';
12
+ import type { ArgDef, CommandDef, FlagDef } from '@celilo/core';
13
13
 
14
14
  /**
15
15
  * Escape a string for use in zsh completion descriptions.
16
- * Colons and backslashes need escaping in _describe arrays.
16
+ * Descriptions are emitted inside single-quoted zsh strings, so:
17
+ * - backslash and colon need escaping in _describe / _arguments specs
18
+ * - a literal apostrophe must close-quote, emit an escaped quote, and reopen
19
+ * ('...'\''...') — otherwise it terminates the string early and the rest of
20
+ * the file mis-parses (e.g. "account's" broke completion at the next `->`).
17
21
  */
18
22
  function escapeZshDescription(text: string): string {
19
- return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:');
23
+ return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "'\\''");
20
24
  }
21
25
 
22
26
  /**
package/src/cli/index.ts CHANGED
@@ -4,11 +4,20 @@
4
4
  * Orchestration function (Rule 10.1) - routes commands to handlers
5
5
  */
6
6
 
7
+ import { COMMANDS, type CommandDef, resolveRemote, runRemoteClient } from '@celilo/core';
7
8
  import * as p from '@clack/prompts';
8
9
  import { CLIServerRequestSchema, parseJsonWithValidation } from '../validation/schemas';
9
- import { COMMANDS, type CommandDef } from './command-registry';
10
+ import {
11
+ handleApiAuthorizedKeys,
12
+ handleApiGrant,
13
+ handleApiKeyNew,
14
+ handleApiList,
15
+ handleApiRevoke,
16
+ } from './commands/api';
17
+ import { handleAptUpgrade } from './commands/apt-upgrade';
10
18
  import { handleCapabilityInfo } from './commands/capability-info';
11
19
  import { handleCapabilityList } from './commands/capability-list';
20
+ import { handleCommands } from './commands/commands-json';
12
21
  import { handleCompletion } from './commands/completion';
13
22
  import { handleDnsRegistrations } from './commands/dns';
14
23
  import {
@@ -180,14 +189,20 @@ Commands:
180
189
  restore Restore a celilo-mgmt backup from a local file (fresh-bootstrap path)
181
190
  machine Manage machine pool (bring-your-own-hardware)
182
191
  system Manage system configuration
192
+ apt-upgrade Upgrade the deb-installed celilo packages + apply migrations
183
193
  ipam Manage IP address and VMID allocations and reservations
184
194
  proxmox Proxmox cluster introspection (proxmox node list)
185
195
  publish Publish workspace packages to npm and modules to celilo.computer
186
196
  subscribers Manage build-bus subscribers (cross-machine publish-event delivery)
197
+ api Manage remote-API access (principals, grants, authorized_keys)
187
198
  completion Generate shell completion scripts (bash/zsh)
199
+ commands Print the CLI command registry as JSON (drives @celilo/mcp)
188
200
 
189
201
  help, --help, -h Show this help message
190
202
 
203
+ Run any command on a remote celilo-mgr over SSH:
204
+ celilo --remote <ssh-dest> <command> (or set CELILO_REMOTE=<ssh-dest>)
205
+
191
206
  For command-specific help:
192
207
  celilo package --help
193
208
  celilo module --help
@@ -1015,6 +1030,24 @@ Using Vault Password:
1015
1030
  export async function runCli(argv: string[]): Promise<CommandResult> {
1016
1031
  const parsed = parseArguments(argv);
1017
1032
 
1033
+ // Remote API server: the sshd forced-command entry point
1034
+ // (`celilo api-serve --principal=<id>`). Not an operator command — kept out
1035
+ // of the registry/completion on purpose. Runs a persistent NDJSON protocol
1036
+ // loop until stdin closes, then exits the process; never returns here.
1037
+ if (parsed.command === 'api-serve') {
1038
+ const principal = typeof parsed.flags.principal === 'string' ? parsed.flags.principal : '';
1039
+ if (!principal) {
1040
+ return {
1041
+ success: false,
1042
+ error:
1043
+ 'api-serve requires --principal <name> (normally supplied by the forced command in authorized_keys)',
1044
+ };
1045
+ }
1046
+ const { apiServeMode } = await import('../api/serve');
1047
+ await apiServeMode(principal);
1048
+ return { success: true, message: '' };
1049
+ }
1050
+
1018
1051
  // Handle --get-completions for shell completion (must be before other processing)
1019
1052
  if (parsed.flags['get-completions']) {
1020
1053
  try {
@@ -1066,11 +1099,20 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1066
1099
  return handleStatus();
1067
1100
  }
1068
1101
 
1102
+ // Handle commands command (serialize the registry; drives @celilo/mcp)
1103
+ if (parsed.command === 'commands') {
1104
+ return handleCommands(parsed.args, parsed.flags);
1105
+ }
1106
+
1069
1107
  // Top-level alias: `celilo audit` → `celilo system audit`
1070
1108
  if (parsed.command === 'audit') {
1071
1109
  return handleSystemAudit(parsed.args, parsed.flags);
1072
1110
  }
1073
1111
 
1112
+ if (parsed.command === 'apt-upgrade') {
1113
+ return handleAptUpgrade(parsed.args, parsed.flags);
1114
+ }
1115
+
1074
1116
  // Route commands
1075
1117
  if (parsed.command === 'package') {
1076
1118
  // Handle package --help
@@ -1261,7 +1303,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1261
1303
  case 'import':
1262
1304
  return handleModuleImport(parsed.args, parsed.flags);
1263
1305
  case 'list':
1264
- return handleModuleList();
1306
+ return handleModuleList(parsed.flags);
1265
1307
  case 'status':
1266
1308
  return handleModuleStatus(parsed.args);
1267
1309
  case 'logs':
@@ -1673,6 +1715,64 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1673
1715
  return handleRestore(restoreArgs, parsed.flags);
1674
1716
  }
1675
1717
 
1718
+ if (parsed.command === 'api') {
1719
+ if (parsed.flags.help || parsed.flags.h) {
1720
+ return {
1721
+ success: true,
1722
+ message: [
1723
+ 'celilo api — manage remote-API access',
1724
+ '',
1725
+ 'Usage:',
1726
+ ' celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>',
1727
+ ' celilo api list',
1728
+ ' celilo api revoke <principal>',
1729
+ ' celilo api authorized-keys',
1730
+ ' celilo api key new <name>',
1731
+ '',
1732
+ 'Grants are command:subcommand (module:deploy), command:* (service:*), or * (all).',
1733
+ ].join('\n'),
1734
+ };
1735
+ }
1736
+
1737
+ if (!parsed.subcommand) {
1738
+ return {
1739
+ success: false,
1740
+ error: 'API subcommand required\n\nRun "celilo api --help" for usage',
1741
+ };
1742
+ }
1743
+
1744
+ const apiFlagError = checkFlags('api', parsed.subcommand, parsed.flags, parsed.args);
1745
+ if (apiFlagError) return apiFlagError;
1746
+
1747
+ if (parsed.subcommand === 'grant') {
1748
+ return handleApiGrant(parsed.args, parsed.flags);
1749
+ }
1750
+
1751
+ if (parsed.subcommand === 'list') {
1752
+ return handleApiList();
1753
+ }
1754
+
1755
+ if (parsed.subcommand === 'revoke') {
1756
+ return handleApiRevoke(parsed.args);
1757
+ }
1758
+
1759
+ if (parsed.subcommand === 'authorized-keys') {
1760
+ return handleApiAuthorizedKeys();
1761
+ }
1762
+
1763
+ if (parsed.subcommand === 'key') {
1764
+ if (parsed.args[0] === 'new') {
1765
+ return handleApiKeyNew(parsed.args.slice(1));
1766
+ }
1767
+ return { success: false, error: 'Usage: celilo api key new <name>' };
1768
+ }
1769
+
1770
+ return {
1771
+ success: false,
1772
+ error: `Unknown api subcommand: ${parsed.subcommand}\n\nRun "celilo api --help" for usage`,
1773
+ };
1774
+ }
1775
+
1676
1776
  if (parsed.command === 'machine') {
1677
1777
  // Handle machine --help
1678
1778
  if (parsed.flags.help || parsed.flags.h) {
@@ -2084,6 +2184,13 @@ export async function main(): Promise<void> {
2084
2184
  return;
2085
2185
  }
2086
2186
 
2187
+ // Remote execution: `celilo --remote <dest> <cmd>` or CELILO_REMOTE=<dest>.
2188
+ // SSH to the remote celilo-mgr and drive its api-serve over the wire.
2189
+ const remote = resolveRemote(process.argv);
2190
+ if (remote) {
2191
+ process.exit(await runRemoteClient(remote.dest, remote.commandArgv));
2192
+ }
2193
+
2087
2194
  // Normal single-command execution
2088
2195
  try {
2089
2196
  const result = await runCli(process.argv);
@@ -107,6 +107,19 @@ describe('CLI Parser', () => {
107
107
  expect(result.flags).toEqual({ json: true, verbose: true });
108
108
  });
109
109
 
110
+ test('should parse --flag=value inline form', () => {
111
+ const result = parseArguments([
112
+ 'node',
113
+ 'celilo',
114
+ 'api-serve',
115
+ '--principal=alice',
116
+ '--empty=',
117
+ ]);
118
+
119
+ expect(result.command).toBe('api-serve');
120
+ expect(result.flags).toEqual({ principal: 'alice', empty: '' });
121
+ });
122
+
110
123
  test('should not consume key=value args as flag values', () => {
111
124
  const result = parseArguments([
112
125
  'node',
package/src/cli/parser.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Policy functions (Rule 10.1) - parsing and validation only
5
5
  */
6
6
 
7
- import type { CommandDef } from './command-registry';
7
+ import type { CommandDef } from '@celilo/core';
8
8
  import type { ParsedCommand } from './types';
9
9
 
10
10
  /**
@@ -82,8 +82,17 @@ export function parseArguments(argv: string[]): ParsedCommand {
82
82
  if (!arg) continue;
83
83
 
84
84
  if (arg.startsWith('--')) {
85
- // Long flag (--help, --output, etc.)
86
- const flagName = arg.slice(2);
85
+ const body = arg.slice(2);
86
+ const eqIndex = body.indexOf('=');
87
+
88
+ // Inline-value form: --flag=value (everything after the first `=`).
89
+ if (eqIndex >= 0) {
90
+ flags[body.slice(0, eqIndex)] = body.slice(eqIndex + 1);
91
+ continue;
92
+ }
93
+
94
+ // Space-separated form: --flag value
95
+ const flagName = body;
87
96
  const nextArg = restArgs[i + 1];
88
97
 
89
98
  // Check if next arg is a value (not another flag, not a key=value pair)
package/src/db/schema.ts CHANGED
@@ -654,6 +654,34 @@ export const aspectApprovals = sqliteTable(
654
654
  }),
655
655
  );
656
656
 
657
+ /**
658
+ * Remote API principals (see v2/API_COMMUNICATION.md).
659
+ *
660
+ * Each row is one API identity: a name, one SSH public key, and the set of
661
+ * operations it may run. celilo renders these into the API account's
662
+ * `authorized_keys` (one forced-command line per row) and authz keys off the
663
+ * principal → `grants`.
664
+ *
665
+ * ponytail: one key per principal (a person wanting a second device makes a
666
+ * second principal, e.g. `alice-laptop`). If multiple keys per identity is ever
667
+ * needed, split into an `api_keys` child table — not worth it yet.
668
+ */
669
+ export const apiPrincipals = sqliteTable('api_principals', {
670
+ id: text('id').primaryKey(), // UUID
671
+ /** Human-readable principal name, kebab-case (e.g. "alice", "ci-deployer"). */
672
+ name: text('name').notNull().unique(),
673
+ /** SSH public key line: `<type> <base64> [comment]`. */
674
+ publicKey: text('public_key').notNull(),
675
+ /**
676
+ * Operations this principal may run, as `command:subcommand` grants
677
+ * (`module:deploy`), `command:*` wildcards (`service:*`), or `*` (all).
678
+ * Deny-by-default: an operation not matched by any grant is refused.
679
+ */
680
+ grants: text('grants', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
681
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
682
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
683
+ });
684
+
657
685
  /**
658
686
  * Type exports for use in application code
659
687
  */
@@ -695,3 +723,5 @@ export type ModuleOperation = typeof moduleOperations.$inferSelect;
695
723
  export type NewModuleOperation = typeof moduleOperations.$inferInsert;
696
724
  export type AspectApproval = typeof aspectApprovals.$inferSelect;
697
725
  export type NewAspectApproval = typeof aspectApprovals.$inferInsert;
726
+ export type ApiPrincipal = typeof apiPrincipals.$inferSelect;
727
+ export type NewApiPrincipal = typeof apiPrincipals.$inferInsert;
@@ -126,4 +126,81 @@ describe('Capability Loader', () => {
126
126
  const consumer = await loadCapabilityFunctions('apt-repo', db, noopLogger);
127
127
  expect(consumer).not.toHaveProperty('web_routes');
128
128
  });
129
+
130
+ // ce-iku regression: public_web's managed-domain check must reflect the
131
+ // registrar's DECLARED `domain_list` computed field (namecheap:
132
+ // keys(secret.ddns_passwords)) — the same live set DDNS validation sees —
133
+ // NOT a stale `config.domains` row that survived an older manifest version.
134
+ // Without the fix the config-shape heuristic short-circuits on the stale
135
+ // config.domains and a just-added domain (present only in the secret) is
136
+ // silently excluded, dead-ending register_route forever.
137
+ test('public_web managed-domains come from the registrar domain_list computed field, not a stale config.domains', async () => {
138
+ const { encryptSecret } = await import('../secrets/encryption');
139
+ const { getOrCreateMasterKey } = await import('../secrets/master-key');
140
+ const { isMissingProviderInputError } = await import('@celilo/capabilities');
141
+ const masterKey = await getOrCreateMasterKey();
142
+
143
+ // Provider: caddy (public_web). Needs ≥1 configured hostname + target_ip
144
+ // for createPublicWeb to build.
145
+ const caddyPath = join(tempDir, 'caddy');
146
+ mkdirSync(caddyPath, { recursive: true });
147
+ db.$client.run(
148
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('caddy', 'Caddy', '1.0.0', '${caddyPath}', '{}')`,
149
+ );
150
+ db.$client.run(
151
+ `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('caddy', 'public_web', '1.0.0', '{}', unixepoch())`,
152
+ );
153
+ upsertModuleConfig(db, 'caddy', 'hostnames', ['seed.celilo.computer']);
154
+ upsertModuleConfig(db, 'caddy', 'target_ip', '10.0.20.10');
155
+
156
+ // Provider: namecheap (dns_registrar). Its `data` declares the canonical
157
+ // domain_list computed field. config.domains is STALE (missing the newly
158
+ // onboarded domain); the SECRET holds the real, current set.
159
+ const ncPath = join(tempDir, 'namecheap');
160
+ mkdirSync(ncPath, { recursive: true });
161
+ const registrarData = JSON.stringify({
162
+ provider: 'namecheap',
163
+ domain_list: { __celilo_computed__: 'keys(secret.ddns_passwords)' },
164
+ });
165
+ db.$client.run(
166
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('namecheap', 'Namecheap', '3.2.0', '${ncPath}', '{}')`,
167
+ );
168
+ db.$client.run(
169
+ `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('namecheap', 'dns_registrar', '4.0.0', '${registrarData}', unixepoch())`,
170
+ );
171
+ // Stale config that must be IGNORED — omits buildyourowninternet.dev.
172
+ upsertModuleConfig(db, 'namecheap', 'domains', ['celilo.computer']);
173
+ // Live secret — the source of truth — includes the new domain.
174
+ const ddnsEnc = encryptSecret(
175
+ JSON.stringify({ 'celilo.computer': 'pw1', 'buildyourowninternet.dev': 'pw2' }),
176
+ masterKey,
177
+ );
178
+ db.$client.run(
179
+ `INSERT INTO secrets (module_id, name, encrypted_value, iv, auth_tag) VALUES ('namecheap', 'ddns_passwords', '${ddnsEnc.encryptedValue}', '${ddnsEnc.iv}', '${ddnsEnc.authTag}')`,
180
+ );
181
+
182
+ const consumer = await loadCapabilityFunctions('byoi', db, noopLogger);
183
+ const publicWeb = consumer.public_web as {
184
+ register_route: (r: { type: string; path: string; hostname: string }) => Promise<unknown>;
185
+ };
186
+ expect(publicWeb).toBeTruthy();
187
+
188
+ // Does register_route reject specifically because the hostname's apex
189
+ // isn't in any managed domain? (Later reconcile errors are a different
190
+ // failure — the domain check already passed by then.)
191
+ async function rejectsAsMissingProvider(hostname: string): Promise<boolean> {
192
+ try {
193
+ await publicWeb.register_route({ type: 'static', path: '/', hostname });
194
+ return false;
195
+ } catch (err) {
196
+ return isMissingProviderInputError(err);
197
+ }
198
+ }
199
+
200
+ // In the secret (domain_list) but NOT in the stale config.domains — must
201
+ // pass the managed-domain check.
202
+ expect(await rejectsAsMissingProvider('www.buildyourowninternet.dev')).toBe(false);
203
+ // In neither the secret nor config — control: must still be rejected.
204
+ expect(await rejectsAsMissingProvider('app.notmanaged.example')).toBe(true);
205
+ });
129
206
  });
@@ -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
+ });