@tinycloud/cli 1.0.0-beta.16 → 1.0.0-beta.17

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.
@@ -37,16 +37,17 @@ import {
37
37
  tinycloudConfigPath,
38
38
  tinycloudHomePath
39
39
  } from "@tinycloud/operations/state";
40
- var CONFIG_DIR, PROFILES_DIR, CONFIG_FILE, DEFAULT_HOST, DEFAULT_OPENKEY_HOST, DEFAULT_OPENKEY_DEVICE_API_HOST, DEFAULT_PROFILE, DEFAULT_CHAIN_ID, ExitCode;
40
+ var CONFIG_DIR, PROFILES_DIR, CONFIG_FILE, DEFAULT_HOST, DEFAULT_OPENKEY_HOST, DEFAULT_OPENKEY_DEVICE_API_HOST, DEFAULT_SHARE_ORIGIN, DEFAULT_PROFILE, DEFAULT_CHAIN_ID, ExitCode;
41
41
  var init_constants = __esm({
42
42
  "src/config/constants.ts"() {
43
43
  "use strict";
44
44
  CONFIG_DIR = tinycloudHomePath();
45
45
  PROFILES_DIR = profilesPath();
46
46
  CONFIG_FILE = tinycloudConfigPath();
47
- DEFAULT_HOST = "https://node.tinycloud.xyz";
47
+ DEFAULT_HOST = "https://tee.node.tinycloud.xyz";
48
48
  DEFAULT_OPENKEY_HOST = "https://openkey.so";
49
49
  DEFAULT_OPENKEY_DEVICE_API_HOST = "https://api.openkey.so";
50
+ DEFAULT_SHARE_ORIGIN = "https://share.tinycloud.xyz";
50
51
  DEFAULT_PROFILE = "default";
51
52
  DEFAULT_CHAIN_ID = 1;
52
53
  ExitCode = {
@@ -80,9 +81,9 @@ async function readJson(filePath) {
80
81
  async function writeJson(filePath, data) {
81
82
  const directory = dirname(filePath);
82
83
  const tempPath = join(directory, `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
83
- await mkdir(directory, { recursive: true });
84
+ await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
84
85
  try {
85
- await writeFile(tempPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
86
+ await writeFile(tempPath, JSON.stringify(data, null, 2) + "\n", { encoding: "utf-8", mode: PRIVATE_FILE_MODE });
86
87
  await rename(tempPath, filePath);
87
88
  } catch (err2) {
88
89
  await rm(tempPath, { force: true }).catch(() => void 0);
@@ -101,7 +102,7 @@ async function fileExists(filePath) {
101
102
  }
102
103
  }
103
104
  async function ensureDir(dirPath) {
104
- await mkdir(dirPath, { recursive: true });
105
+ await mkdir(dirPath, { recursive: true, mode: PRIVATE_DIR_MODE });
105
106
  }
106
107
  async function removeDir(dirPath) {
107
108
  await rm(dirPath, { recursive: true, force: true });
@@ -117,9 +118,12 @@ async function listDirs(dirPath) {
117
118
  throw err2;
118
119
  }
119
120
  }
121
+ var PRIVATE_FILE_MODE, PRIVATE_DIR_MODE;
120
122
  var init_storage = __esm({
121
123
  "src/config/storage.ts"() {
122
124
  "use strict";
125
+ PRIVATE_FILE_MODE = 384;
126
+ PRIVATE_DIR_MODE = 448;
123
127
  }
124
128
  });
125
129
 
@@ -258,6 +262,7 @@ var init_formatter = __esm({
258
262
  // src/output/errors.ts
259
263
  import { readFileSync, readdirSync } from "fs";
260
264
  import { join as join2 } from "path";
265
+ import { ProfileLockTimeoutError } from "@tinycloud/operations/state";
261
266
  function setActiveProfileName(name) {
262
267
  activeProfileName = name;
263
268
  }
@@ -275,6 +280,14 @@ function wrapError(error) {
275
280
  );
276
281
  }
277
282
  if (error instanceof CLIError) return error;
283
+ if (error instanceof ProfileLockTimeoutError) {
284
+ return new CLIError(
285
+ "PROFILE_LOCK_TIMEOUT",
286
+ `${message} Another tc or MCP process held this profile's lock, so the change that needed it was not written.`,
287
+ ExitCode.ERROR,
288
+ { hint: "Wait for the other command to finish and retry. A crashed process's lock is reclaimed automatically after 30 s." }
289
+ );
290
+ }
278
291
  if (message.includes("Not signed in") || message.includes("AUTH_EXPIRED") || message.includes("Session expired")) {
279
292
  return new CLIError("AUTH_REQUIRED", message, ExitCode.AUTH_REQUIRED);
280
293
  }
@@ -414,6 +427,12 @@ function canonicalizeAddress(address) {
414
427
  const trimmed = address.trim();
415
428
  return trimmed.startsWith("0x") ? `0x${trimmed.slice(2).toLowerCase()}` : trimmed.toLowerCase();
416
429
  }
430
+ function normalizePkhIdentifier(identifier) {
431
+ return identifier.replace(
432
+ /(eip155:\d+:)(0x[0-9a-fA-F]{40})(?=[:#]|$)/,
433
+ (_match, prefix, address) => prefix + address.toLowerCase()
434
+ );
435
+ }
417
436
  function parsePkhDid(did) {
418
437
  const match = did.match(/^did:pkh:eip155:(\d+):(0x[a-fA-F0-9]{40})$/);
419
438
  if (!match) return null;
@@ -520,17 +539,14 @@ function profileLocalNodeIdentityStore(profileName) {
520
539
  return profile?.pinnedLocalNodeDids?.[url];
521
540
  },
522
541
  set: async (url, nodeDid) => {
523
- const profile = await ProfileManager.getProfile(profileName).catch(
524
- () => null
525
- );
526
- if (!profile) return;
527
- await ProfileManager.setProfile(profileName, {
542
+ if (!await ProfileManager.profileExists(profileName)) return;
543
+ await ProfileManager.updateProfile(profileName, (profile) => ({
528
544
  ...profile,
529
545
  pinnedLocalNodeDids: {
530
546
  ...profile.pinnedLocalNodeDids,
531
547
  [url]: nodeDid
532
548
  }
533
- });
549
+ }));
534
550
  }
535
551
  };
536
552
  }
@@ -625,10 +641,12 @@ var init_host = __esm({
625
641
  });
626
642
 
627
643
  // src/config/profiles.ts
644
+ import { chmod, rm as rm2 } from "fs/promises";
628
645
  import { join as join3 } from "path";
629
646
  import {
630
647
  readSession,
631
648
  removeSession,
649
+ withProfileLock,
632
650
  writeSession
633
651
  } from "@tinycloud/operations/state";
634
652
  var ProfileManager;
@@ -641,11 +659,41 @@ var init_profiles = __esm({
641
659
  ProfileManager = class _ProfileManager {
642
660
  // ── Initialization ──────────────────────────────────────────────────
643
661
  /**
644
- * Creates ~/.tinycloud/ and ~/.tinycloud/profiles/ if they don't exist.
662
+ * Runs `action` holding the profile's store lock (shared with operations
663
+ * and MCP). Reentrant, so the writers below can be called inside it; hold
664
+ * it around a whole read-modify-write, not just each write.
665
+ */
666
+ static async withLock(name, action, options) {
667
+ return withProfileLock(name, action, options);
668
+ }
669
+ /**
670
+ * Read-modify-write of profile.json under the profile lock, so concurrent
671
+ * updates (another command, a login commit) are never erased.
672
+ */
673
+ static async updateProfile(name, update) {
674
+ return _ProfileManager.withLock(name, async () => {
675
+ const next = update(await _ProfileManager.getProfile(name));
676
+ await _ProfileManager.setProfile(name, next);
677
+ return next;
678
+ });
679
+ }
680
+ /**
681
+ * Creates ~/.tinycloud/ and ~/.tinycloud/profiles/ if they don't exist and
682
+ * (re)sets both to 0700: older releases created them 0775.
645
683
  */
646
684
  static async ensureConfigDir() {
647
- await ensureDir(CONFIG_DIR);
648
- await ensureDir(PROFILES_DIR);
685
+ for (const directory of [CONFIG_DIR, PROFILES_DIR]) {
686
+ await ensureDir(directory);
687
+ await chmod(directory, PRIVATE_DIR_MODE);
688
+ }
689
+ }
690
+ /** Owner-only profile directory (0700), created or tightened before any write into it. */
691
+ static async ensureProfileDir(name) {
692
+ await _ProfileManager.ensureConfigDir();
693
+ const profileDir = join3(PROFILES_DIR, name);
694
+ await ensureDir(profileDir);
695
+ await chmod(profileDir, PRIVATE_DIR_MODE);
696
+ return profileDir;
649
697
  }
650
698
  // ── Global config ───────────────────────────────────────────────────
651
699
  /**
@@ -682,12 +730,17 @@ var init_profiles = __esm({
682
730
  return profile;
683
731
  }
684
732
  /**
685
- * Saves a profile config, creating the profile directory if needed.
733
+ * Saves a profile config under the profile lock, creating the profile
734
+ * directory if needed. Use `updateProfile` to change an existing profile.
686
735
  */
687
736
  static async setProfile(name, data) {
688
- const profileDir = join3(PROFILES_DIR, name);
689
- await ensureDir(profileDir);
690
- await writeJson(join3(profileDir, "profile.json"), data);
737
+ await _ProfileManager.withLock(name, async () => {
738
+ await writeJson(join3(await _ProfileManager.ensureProfileDir(name), "profile.json"), data);
739
+ });
740
+ }
741
+ /** Removes profile.json under the profile lock (rollback of a profile a failed login created). */
742
+ static async removeProfileConfig(name) {
743
+ await _ProfileManager.withLock(name, () => rm2(join3(PROFILES_DIR, name, "profile.json"), { force: true }));
691
744
  }
692
745
  /**
693
746
  * Returns true if a profile directory exists.
@@ -723,13 +776,15 @@ var init_profiles = __esm({
723
776
  static async getKey(name) {
724
777
  return readJson(join3(PROFILES_DIR, name, "key.json"));
725
778
  }
726
- /**
727
- * Saves a JWK key for a profile.
728
- */
779
+ /** Saves a JWK key under the profile lock (0600, in an owner-only profile directory). */
729
780
  static async setKey(name, jwk) {
730
- const profileDir = join3(PROFILES_DIR, name);
731
- await ensureDir(profileDir);
732
- await writeJson(join3(profileDir, "key.json"), jwk);
781
+ await _ProfileManager.withLock(name, async () => {
782
+ await writeJson(join3(await _ProfileManager.ensureProfileDir(name), "key.json"), jwk);
783
+ });
784
+ }
785
+ /** Removes key.json under the profile lock (rollback of a key a failed login created). */
786
+ static async removeKey(name) {
787
+ await _ProfileManager.withLock(name, () => rm2(join3(PROFILES_DIR, name, "key.json"), { force: true }));
733
788
  }
734
789
  // ── Session management ──────────────────────────────────────────────
735
790
  /**
@@ -752,11 +807,13 @@ var init_profiles = __esm({
752
807
  }
753
808
  // ── Cache management ────────────────────────────────────────────────
754
809
  /**
755
- * Returns the path to the profile's cache directory, creating it if needed.
810
+ * Returns the profile's cache directory (share history lives here),
811
+ * created or tightened to 0700.
756
812
  */
757
813
  static async getCacheDir(name) {
758
- const cacheDir = join3(PROFILES_DIR, name, "cache");
814
+ const cacheDir = join3(await _ProfileManager.ensureProfileDir(name), "cache");
759
815
  await ensureDir(cacheDir);
816
+ await chmod(cacheDir, PRIVATE_DIR_MODE);
760
817
  return cacheDir;
761
818
  }
762
819
  // ── Resolution helpers ──────────────────────────────────────────────
@@ -1678,7 +1735,7 @@ init_constants();
1678
1735
  import { TinyCloudNode } from "@tinycloud/node-sdk";
1679
1736
 
1680
1737
  // src/lib/permissions.ts
1681
- import { appendFile, readFile as readFile2 } from "fs/promises";
1738
+ import { appendFile, chmod as chmod2, readFile as readFile2 } from "fs/promises";
1682
1739
  import { join as join4 } from "path";
1683
1740
  import {
1684
1741
  buildPermissionRequestArtifact,
@@ -1691,7 +1748,7 @@ import {
1691
1748
  readAdditionalDelegations,
1692
1749
  readAuthRequests,
1693
1750
  upsertProfileRecord,
1694
- withProfileLock,
1751
+ withProfileLock as withProfileLock2,
1695
1752
  writeJsonAtomic
1696
1753
  } from "@tinycloud/operations/state";
1697
1754
 
@@ -2730,15 +2787,15 @@ var makeIssue = (params) => {
2730
2787
  message: issueData.message
2731
2788
  };
2732
2789
  }
2733
- let errorMessage = "";
2790
+ let errorMessage2 = "";
2734
2791
  const maps = errorMaps.filter((m) => !!m).slice().reverse();
2735
2792
  for (const map of maps) {
2736
- errorMessage = map(fullIssue, { data, defaultError: errorMessage }).message;
2793
+ errorMessage2 = map(fullIssue, { data, defaultError: errorMessage2 }).message;
2737
2794
  }
2738
2795
  return {
2739
2796
  ...issueData,
2740
2797
  path: fullPath,
2741
- message: errorMessage
2798
+ message: errorMessage2
2742
2799
  };
2743
2800
  };
2744
2801
  var EMPTY_PATH = [];
@@ -12935,6 +12992,44 @@ init_profiles();
12935
12992
  init_errors();
12936
12993
  init_constants();
12937
12994
  init_space();
12995
+
12996
+ // src/share/publishing-manifest.ts
12997
+ var SHARE_PUBLISHING_MANIFEST_REF = "builtin:share-publishing";
12998
+ var SHARE_PUBLISHING_MANIFEST = {
12999
+ app_id: "xyz.tinycloud.share",
13000
+ name: "TinyCloud Share publishing",
13001
+ space: "default",
13002
+ permissions: [
13003
+ {
13004
+ service: "tinycloud.capabilities",
13005
+ path: "",
13006
+ skipPrefix: true,
13007
+ actions: ["tinycloud.capabilities/read"]
13008
+ },
13009
+ {
13010
+ service: "tinycloud.kv",
13011
+ path: "xyz.tinycloud.share/shares/",
13012
+ skipPrefix: true,
13013
+ actions: ["tinycloud.kv/get", "tinycloud.kv/put"]
13014
+ },
13015
+ {
13016
+ service: "tinycloud.kv",
13017
+ path: "shares/",
13018
+ skipPrefix: true,
13019
+ actions: ["tinycloud.kv/get", "tinycloud.kv/metadata", "tinycloud.kv/put", "tinycloud.kv/list"]
13020
+ }
13021
+ ]
13022
+ };
13023
+ function sharePublishingPermissions() {
13024
+ return SHARE_PUBLISHING_MANIFEST.permissions.map((permission) => ({
13025
+ service: permission.service,
13026
+ space: SHARE_PUBLISHING_MANIFEST.space,
13027
+ path: permission.path,
13028
+ actions: [...permission.actions]
13029
+ }));
13030
+ }
13031
+
13032
+ // src/lib/permissions.ts
12938
13033
  init_types();
12939
13034
  function isCompatiblePermissionRequestArtifact(value) {
12940
13035
  return isPermissionRequestArtifact(value) || isNodeSdkAuthRequestArtifact(value);
@@ -12991,7 +13086,7 @@ async function loadPermissionRequestArtifacts(profile) {
12991
13086
  return raw.filter(isCompatiblePermissionRequestArtifact);
12992
13087
  }
12993
13088
  async function appendPermissionRequestArtifact(profile, artifact) {
12994
- await withProfileLock(profile, async () => {
13089
+ await withProfileLock2(profile, async () => {
12995
13090
  const existing = (await readAuthRequests(profile)).filter(isCompatiblePermissionRequestArtifact);
12996
13091
  const next = existing.filter((item) => item.requestId !== artifact.requestId);
12997
13092
  next.push(artifact);
@@ -13030,14 +13125,15 @@ function storedAdditionalDelegation(delegation, permissions) {
13030
13125
  return { delegation, permissions };
13031
13126
  }
13032
13127
  async function appendGrantHistory(profile, entry) {
13033
- const profileDir = join4(PROFILES_DIR, profile);
13034
- await ensureDir(profileDir);
13128
+ await ProfileManager.ensureProfileDir(profile);
13035
13129
  const line = JSON.stringify({
13036
13130
  ts: (/* @__PURE__ */ new Date()).toISOString(),
13037
13131
  profile,
13038
13132
  ...entry
13039
13133
  }) + "\n";
13040
- await appendFile(grantHistoryPath(profile), line, "utf8");
13134
+ const path = grantHistoryPath(profile);
13135
+ await appendFile(path, line, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
13136
+ await chmod2(path, PRIVATE_FILE_MODE);
13041
13137
  }
13042
13138
  async function readGrantHistory(profile) {
13043
13139
  const path = grantHistoryPath(profile);
@@ -13081,12 +13177,12 @@ async function loadPermissionRequest(source, profile) {
13081
13177
  }
13082
13178
  return resolvePermissionSpaces(raw.permissions, profile);
13083
13179
  }
13084
- async function loadManifestPermissions(source, profile) {
13180
+ async function loadManifestPermissions(source, profile, options = {}) {
13085
13181
  const raw = await loadManifestText(source);
13086
13182
  const manifest = JSON.parse(raw);
13087
13183
  if (typeof manifest.id === "string") {
13088
13184
  const resolved = resolveManifest(manifest);
13089
- return resolvePermissionSpaces(resolved.resources, profile);
13185
+ return resolvePermissionSpaces(resolved.resources, profile, options);
13090
13186
  }
13091
13187
  if (typeof manifest.app_id === "string") {
13092
13188
  const permissions = (manifest.permissions ?? []).filter((entry) => entry !== null && typeof entry === "object").map((entry) => {
@@ -13105,7 +13201,7 @@ async function loadManifestPermissions(source, profile) {
13105
13201
  };
13106
13202
  });
13107
13203
  permissions.push(...await secretPermissionsFromAppManifest(manifest, profile));
13108
- return resolvePermissionSpaces(permissions, profile);
13204
+ return resolvePermissionSpaces(permissions, profile, options);
13109
13205
  }
13110
13206
  throw new CLIError(
13111
13207
  "INVALID_MANIFEST",
@@ -13159,7 +13255,8 @@ function permissionsFromDelegation(delegation) {
13159
13255
  service: resource.service.startsWith("tinycloud.") ? resource.service : `tinycloud.${resource.service}`,
13160
13256
  space: resource.space,
13161
13257
  path: resource.path,
13162
- actions: [...resource.actions]
13258
+ actions: [...resource.actions],
13259
+ ...resource.caveats?.length ? { caveats: resource.caveats.map((caveat) => structuredClone(caveat)) } : {}
13163
13260
  }));
13164
13261
  }
13165
13262
  return [{
@@ -13175,9 +13272,9 @@ function compactPermission(permission) {
13175
13272
  const actions = permission.actions.map((action) => action.startsWith(`${service}/`) ? action.slice(service.length + 1) : action).join(",");
13176
13273
  return `${service}:${space}:${permission.path}:${actions}`;
13177
13274
  }
13178
- async function resolvePermissionSpaces(entries, profile) {
13275
+ async function resolvePermissionSpaces(entries, profile, options = {}) {
13179
13276
  const profileConfig = await ProfileManager.getProfile(profile);
13180
- const allowLogicalSpaces = resolveProfilePosture(profileConfig) === "delegate-session";
13277
+ const allowLogicalSpaces = options.allowLogicalSpaces === true || resolveProfilePosture(profileConfig) === "delegate-session";
13181
13278
  const resolved = [];
13182
13279
  for (const entry of entries) {
13183
13280
  const service = normalizeService(entry.service);
@@ -13200,6 +13297,9 @@ async function resolvePermissionSpaces(entries, profile) {
13200
13297
  return resolved;
13201
13298
  }
13202
13299
  async function loadManifestText(source) {
13300
+ if (source === SHARE_PUBLISHING_MANIFEST_REF) {
13301
+ return JSON.stringify(SHARE_PUBLISHING_MANIFEST);
13302
+ }
13203
13303
  if (source.startsWith("base64:")) {
13204
13304
  return Buffer.from(source.slice("base64:".length), "base64").toString("utf8");
13205
13305
  }
@@ -13360,17 +13460,19 @@ async function bootstrapDelegatedSession(ctx, delegation) {
13360
13460
  }
13361
13461
  const key = await ProfileManager.getKey(ctx.profile);
13362
13462
  const jwk = signerJwkForProfile(ctx.profile, void 0, key);
13363
- await ProfileManager.setSession(ctx.profile, {
13364
- delegationHeader: delegation.delegationHeader,
13365
- delegationCid: delegation.cid,
13366
- spaceId: delegation.spaceId,
13367
- jwk,
13368
- verificationMethod: sessionDid
13369
- });
13370
- await ProfileManager.setProfile(ctx.profile, {
13371
- ...profile,
13372
- sessionDid,
13373
- spaceId: delegation.spaceId
13463
+ await ProfileManager.withLock(ctx.profile, async () => {
13464
+ await ProfileManager.setSession(ctx.profile, {
13465
+ delegationHeader: delegation.delegationHeader,
13466
+ delegationCid: delegation.cid,
13467
+ spaceId: delegation.spaceId,
13468
+ jwk,
13469
+ verificationMethod: sessionDid
13470
+ });
13471
+ await ProfileManager.updateProfile(ctx.profile, (current) => ({
13472
+ ...current,
13473
+ sessionDid,
13474
+ spaceId: delegation.spaceId
13475
+ }));
13374
13476
  });
13375
13477
  return createSDKInstance(ctx);
13376
13478
  }
@@ -14003,14 +14105,12 @@ Open this URL in a browser to authenticate:
14003
14105
 
14004
14106
  // src/auth/device-auth.ts
14005
14107
  init_constants();
14006
- init_profiles();
14108
+ init_errors();
14007
14109
  import {
14008
14110
  createDecipheriv,
14111
+ createECDH,
14009
14112
  createHash,
14010
14113
  createHmac,
14011
- createPublicKey,
14012
- diffieHellman,
14013
- generateKeyPairSync,
14014
14114
  randomBytes as randomBytes3
14015
14115
  } from "crypto";
14016
14116
 
@@ -14084,14 +14184,364 @@ async function localKeySignIn(options) {
14084
14184
  };
14085
14185
  }
14086
14186
 
14187
+ // src/auth/scoped-login.ts
14188
+ init_errors();
14189
+ init_constants();
14190
+ init_types();
14191
+ import { NodeWasmBindings } from "@tinycloud/node-sdk";
14192
+
14193
+ // src/lib/duration.ts
14194
+ function parseDuration(input) {
14195
+ const match = input.match(/^(\d+)(m|h|d|w)$/);
14196
+ if (match) {
14197
+ const value = parseInt(match[1], 10);
14198
+ const unit = match[2];
14199
+ const multipliers = {
14200
+ m: 60 * 1e3,
14201
+ h: 60 * 60 * 1e3,
14202
+ d: 24 * 60 * 60 * 1e3,
14203
+ w: 7 * 24 * 60 * 60 * 1e3
14204
+ };
14205
+ return value * multipliers[unit];
14206
+ }
14207
+ const date = new Date(input);
14208
+ if (!isNaN(date.getTime())) {
14209
+ const ms2 = date.getTime() - Date.now();
14210
+ if (ms2 <= 0) {
14211
+ throw new Error(`Expiry date "${input}" is in the past`);
14212
+ }
14213
+ return ms2;
14214
+ }
14215
+ throw new Error(`Invalid duration: "${input}". Use format like "1h", "7d", or an ISO date.`);
14216
+ }
14217
+ function parseExpiry2(input) {
14218
+ return new Date(Date.now() + parseDuration(input));
14219
+ }
14220
+
14221
+ // src/auth/scoped-login.ts
14222
+ init_space();
14223
+ var CLOCK_SKEW_MS = 3e4;
14224
+ var SIGNED_RECAP = "signed-recap";
14225
+ function parseRequestedExpiry(value) {
14226
+ if (typeof value === "number") return { durationMs: value };
14227
+ if (/^\d+(m|h|d|w)$/.test(value)) return { durationMs: parseDuration(value) };
14228
+ throw new CLIError(
14229
+ "INVALID_EXPIRY",
14230
+ Number.isFinite(Date.parse(value)) ? `--expiry "${value}" is a date. OpenKey signs approval time plus a lifetime, so use a duration such as 2h or 7d.` : `Invalid --expiry "${value}". Use a duration such as 1h or 7d, or milliseconds.`,
14231
+ ExitCode.USAGE_ERROR
14232
+ );
14233
+ }
14234
+ function expiryLimit(expiry) {
14235
+ return Date.now() + expiry.durationMs + CLOCK_SKEW_MS;
14236
+ }
14237
+ var OPENKEY_MIN_LIFETIME_SECONDS = 60;
14238
+ function openKeyExpiryParam(expiry) {
14239
+ const seconds = Math.floor(expiry.durationMs / 1e3);
14240
+ if (seconds < OPENKEY_MIN_LIFETIME_SECONDS) {
14241
+ throw new CLIError("INVALID_EXPIRY", "--expiry must be at least 1 minute: OpenKey does not sign shorter sessions.", ExitCode.USAGE_ERROR);
14242
+ }
14243
+ return `${seconds}s`;
14244
+ }
14245
+ function isLocalOwnerProfile(profile) {
14246
+ return resolveProfilePosture(profile) === "local-owner-key" || profile.authMethod === "local" || typeof profile.privateKey === "string";
14247
+ }
14248
+ function pinnedOwner(profile) {
14249
+ return profile && !isLocalOwnerProfile(profile) ? profile.ownerDid : void 0;
14250
+ }
14251
+ var TRUST_FIELDS = ["ownerDid", "permissions", "permissionsSource", "expiresAt", "expiry", "expirationTime"];
14252
+ function withoutTrustFields(session) {
14253
+ return Object.fromEntries(Object.entries(session).filter(([name]) => !TRUST_FIELDS.includes(name)));
14254
+ }
14255
+ function withVerifiedAuthority(session, signed) {
14256
+ return {
14257
+ ...withoutTrustFields(session),
14258
+ ownerDid: signed.ownerDid,
14259
+ permissions: signed.permissions,
14260
+ permissionsSource: SIGNED_RECAP,
14261
+ expiresAt: signed.expiresAt,
14262
+ expiry: signed.expiresAt,
14263
+ expirationTime: signed.expiresAt
14264
+ };
14265
+ }
14266
+ function expectedOwnerFor(profileName, profile, requested) {
14267
+ const pinned = pinnedOwner(profile);
14268
+ if (pinned && requested && normalizePkhIdentifier(pinned) !== normalizePkhIdentifier(requested)) {
14269
+ throw new CLIError("OPENKEY_OWNER_MISMATCH", `Profile "${profileName}" belongs to ${pinned}, not ${requested}. Use a new profile for another account.`, ExitCode.USAGE_ERROR);
14270
+ }
14271
+ return pinned ?? requested;
14272
+ }
14273
+ function sessionExpiresAt(session) {
14274
+ if (session === null) return null;
14275
+ const candidates = [
14276
+ session.expiresAt,
14277
+ session.expiry,
14278
+ session.expirationTime,
14279
+ typeof session.siwe === "string" ? session.siwe.match(/^Expiration Time:\s*(.+)$/m)?.[1] : void 0
14280
+ ];
14281
+ const value = candidates.find((candidate) => typeof candidate === "string" && Number.isFinite(Date.parse(candidate)));
14282
+ return typeof value === "string" ? new Date(value).toISOString() : null;
14283
+ }
14284
+ function ownerSpaceId(space, ownerDid) {
14285
+ return space.startsWith("tinycloud:") ? space : `tinycloud:${ownerDid.slice("did:".length)}:${space}`;
14286
+ }
14287
+ function permissionTuples(permissions, ownerDid) {
14288
+ return new Set(permissions.flatMap((permission) => actionTuples(permission, ownerDid)));
14289
+ }
14290
+ function actionTuples(permission, ownerDid) {
14291
+ const service = permission.service.startsWith("tinycloud.") ? permission.service : `tinycloud.${permission.service}`;
14292
+ const space = normalizePkhIdentifier(ownerSpaceId(permission.space ?? "", ownerDid));
14293
+ return permission.actions.map((action) => JSON.stringify([service, space, permission.path, action.includes("/") ? action : `${service}/${action}`]));
14294
+ }
14295
+ function canonicalJson(value) {
14296
+ const canonical = (entry) => Array.isArray(entry) ? entry.map(canonical) : entry && typeof entry === "object" ? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)).map(([name, inner]) => [name, canonical(inner)])) : entry;
14297
+ return JSON.stringify(canonical(value) ?? null);
14298
+ }
14299
+ function caveatRestriction(caveats) {
14300
+ const restrictions = (caveats ?? []).filter((caveat) => Object.keys(caveat).length > 0).map(canonicalJson).sort();
14301
+ return restrictions.length === 0 ? "" : JSON.stringify(restrictions);
14302
+ }
14303
+ function scopeCovers(granted, held, ownerDid) {
14304
+ const grants = /* @__PURE__ */ new Map();
14305
+ for (const permission of granted) {
14306
+ const restriction = caveatRestriction(permission.caveats);
14307
+ for (const tuple of actionTuples(permission, ownerDid)) {
14308
+ const restrictions = grants.get(tuple) ?? /* @__PURE__ */ new Set();
14309
+ restrictions.add(restriction);
14310
+ grants.set(tuple, restrictions);
14311
+ }
14312
+ }
14313
+ return held.every((permission) => {
14314
+ const restriction = caveatRestriction(permission.caveats);
14315
+ return actionTuples(permission, ownerDid).every((tuple) => {
14316
+ const restrictions = grants.get(tuple);
14317
+ return restrictions !== void 0 && (restrictions.has("") || restrictions.has(restriction));
14318
+ });
14319
+ });
14320
+ }
14321
+ function plainCaveat(value) {
14322
+ if (value === void 0 || value === null) return null;
14323
+ if (typeof value === "boolean" || typeof value === "string") return value;
14324
+ if (typeof value === "number" && Number.isFinite(value)) return value;
14325
+ if (Array.isArray(value)) return value.map(plainCaveat);
14326
+ if (value instanceof Map) {
14327
+ return Object.fromEntries([...value].map(([name, inner]) => {
14328
+ if (typeof name !== "string") throw new Error("ReCap caveat keys must be strings");
14329
+ return [name, plainCaveat(inner)];
14330
+ }));
14331
+ }
14332
+ if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
14333
+ return Object.fromEntries(Object.entries(value).map(([name, inner]) => [name, plainCaveat(inner)]));
14334
+ }
14335
+ throw new Error("ReCap caveats must contain only JSON values");
14336
+ }
14337
+ function plainCaveats(caveats) {
14338
+ return (caveats ?? []).map((caveat) => {
14339
+ const plain = plainCaveat(caveat);
14340
+ if (plain === null || typeof plain !== "object" || Array.isArray(plain)) throw new Error("ReCap caveats must be objects");
14341
+ return plain;
14342
+ }).filter((caveat) => Object.keys(caveat).length > 0);
14343
+ }
14344
+ function permissionsFromTuples(tuples) {
14345
+ const grouped = /* @__PURE__ */ new Map();
14346
+ for (const tuple of tuples) {
14347
+ const [service, space, path, action] = JSON.parse(tuple);
14348
+ const key = JSON.stringify([service, space, path]);
14349
+ const entry = grouped.get(key) ?? { service, space, path, actions: [] };
14350
+ entry.actions.push(action);
14351
+ grouped.set(key, entry);
14352
+ }
14353
+ return [...grouped.values()];
14354
+ }
14355
+ function validateLoginPermissions(permissions) {
14356
+ if (!permissions.length || permissions.some(
14357
+ (p) => !p.service?.startsWith("tinycloud.") || !p.space || !p.space.startsWith("tinycloud:") && !/^[A-Za-z0-9_-]+$/.test(p.space) || typeof p.path !== "string" || !p.actions?.length || p.actions.some((action) => !action.startsWith(`${p.service}/`))
14358
+ ) || new Set(permissions.map((p) => normalizePkhIdentifier(p.space ?? ""))).size !== 1) {
14359
+ throw new CLIError("INVALID_LOGIN_SCOPE", "First login requires non-empty permissions in one TinyCloud space. Request additional spaces after login.", ExitCode.USAGE_ERROR);
14360
+ }
14361
+ }
14362
+ function verifySignedSession(data, key, sessionDid, expected = {}) {
14363
+ let permissions;
14364
+ let expiresAt;
14365
+ try {
14366
+ if (typeof data.siwe !== "string" || typeof data.signature !== "string" || typeof data.address !== "string" || !Number.isSafeInteger(data.chainId) || typeof data.spaceId !== "string" || typeof data.delegationCid !== "string" || !data.delegationHeader || typeof data.delegationHeader !== "object" || typeof data.verificationMethod !== "string" || data.verificationMethod.split("#")[0] !== sessionDid.split("#")[0]) throw new Error();
14367
+ const proof = new NodeWasmBindings().validatePersistedSession({
14368
+ delegationHeader: data.delegationHeader,
14369
+ delegationCid: data.delegationCid,
14370
+ spaceId: data.spaceId,
14371
+ jwk: key,
14372
+ address: data.address,
14373
+ chainId: data.chainId,
14374
+ siwe: data.siwe,
14375
+ signature: data.signature
14376
+ });
14377
+ if (!proof.verifiedRecap?.length || !proof.expiresAt || !Number.isFinite(Date.parse(proof.expiresAt))) throw new Error();
14378
+ permissions = proof.verifiedRecap.map((entry) => {
14379
+ const service = entry.service.startsWith("tinycloud.") ? entry.service : `tinycloud.${entry.service}`;
14380
+ const actions = entry.actions.map((action) => action.includes("/") ? action : `${service}/${action}`);
14381
+ const caveats = plainCaveats(entry.caveats);
14382
+ return { service, space: entry.space, path: entry.path, actions, ...caveats.length > 0 ? { caveats } : {} };
14383
+ });
14384
+ expiresAt = proof.expiresAt;
14385
+ } catch (error) {
14386
+ if (/expir/i.test(error instanceof Error ? error.message : String(error))) {
14387
+ throw new CLIError("AUTH_EXPIRED", "The approved session has expired. No session was saved.", ExitCode.AUTH_REQUIRED);
14388
+ }
14389
+ throw new CLIError("OPENKEY_PROOF_INVALID", "OpenKey did not return a complete, verifiable session proof. No session was saved.", ExitCode.AUTH_REQUIRED);
14390
+ }
14391
+ const signedExpiry = Date.parse(expiresAt);
14392
+ if (signedExpiry <= Date.now()) {
14393
+ throw new CLIError("AUTH_EXPIRED", "The approved session has expired. No session was saved.", ExitCode.AUTH_REQUIRED);
14394
+ }
14395
+ if (expected.expiry !== void 0 && signedExpiry > expiryLimit(expected.expiry)) {
14396
+ throw new CLIError("OPENKEY_EXPIRY_EXCEEDED", "The signed session outlives the requested --expiry. No session was saved.", ExitCode.PERMISSION_DENIED);
14397
+ }
14398
+ const ownerDid = `did:pkh:eip155:${data.chainId}:${data.address}`;
14399
+ if (expected.expectedOwner && normalizePkhIdentifier(expected.expectedOwner) !== normalizePkhIdentifier(ownerDid)) {
14400
+ throw new CLIError("OPENKEY_OWNER_MISMATCH", "The approved signing identity differs from this profile's owner. No session was saved. Use a new profile for another account.", ExitCode.PERMISSION_DENIED);
14401
+ }
14402
+ return { ownerDid, permissions, expiresAt };
14403
+ }
14404
+ function verifyScopedLogin(data, key, sessionDid, requested, expected = {}) {
14405
+ const signed = verifySignedSession(data, key, sessionDid, expected);
14406
+ const spaceId = data.spaceId;
14407
+ for (const permission of requested) {
14408
+ if (normalizePkhIdentifier(ownerSpaceId(permission.space ?? "", signed.ownerDid)) !== normalizePkhIdentifier(spaceId)) {
14409
+ throw new CLIError("OPENKEY_SCOPE_MISMATCH", "The approved space differs from the requested space. No scoped session was saved.", ExitCode.PERMISSION_DENIED);
14410
+ }
14411
+ }
14412
+ if (!scopeCovers(requested, signed.permissions, signed.ownerDid)) {
14413
+ throw new CLIError("OPENKEY_GRANT_BROADENED", "The signed grant contains authority beyond the requested manifest. No scoped session was saved.", ExitCode.PERMISSION_DENIED);
14414
+ }
14415
+ return withVerifiedAuthority({ ...data, jwk: key }, signed);
14416
+ }
14417
+
14418
+ // src/auth/login-commit.ts
14419
+ init_constants();
14420
+ init_profiles();
14421
+ init_errors();
14422
+ init_space();
14423
+ import { ProfileLockTimeoutError as ProfileLockTimeoutError2 } from "@tinycloud/operations/state";
14424
+ function assertNotLocalOwner(profileName, profile, flow) {
14425
+ if (profile === null || !isLocalOwnerProfile(profile)) return;
14426
+ throw new CLIError(
14427
+ "LOCAL_OWNER_PROFILE",
14428
+ `Profile "${profileName}" holds a local owner key. ${flow} would turn it into an OpenKey profile while keeping that key. Use a separate profile: \`tc init --name publisher --key-only\`, then \`tc --profile publisher auth login --device --manifest ...\`.`,
14429
+ ExitCode.USAGE_ERROR
14430
+ );
14431
+ }
14432
+ async function readProfileSnapshot(profileName) {
14433
+ const profile = await ProfileManager.getProfile(profileName).catch((error) => {
14434
+ if (error instanceof CLIError && error.code === "PROFILE_NOT_FOUND") return null;
14435
+ throw error;
14436
+ });
14437
+ return {
14438
+ profile,
14439
+ key: await ProfileManager.getKey(profileName),
14440
+ session: await ProfileManager.getSession(profileName)
14441
+ };
14442
+ }
14443
+ function inconsistency(snapshot) {
14444
+ const { profile, key, session } = snapshot;
14445
+ if (session === null) return void 0;
14446
+ const sessionKeyDid = typeof session.verificationMethod === "string" ? session.verificationMethod.split("#")[0] : void 0;
14447
+ if (sessionKeyDid !== void 0 && key !== null && keyToDID(key).split("#")[0] !== sessionKeyDid) return "the session belongs to another key";
14448
+ if (sessionKeyDid !== void 0 && typeof profile?.sessionDid === "string" && profile.sessionDid.split("#")[0] !== sessionKeyDid) {
14449
+ return "the session belongs to another session DID than the profile records";
14450
+ }
14451
+ if (typeof session.spaceId === "string" && typeof profile?.spaceId === "string" && normalizePkhIdentifier(session.spaceId) !== normalizePkhIdentifier(profile.spaceId)) return "the session's space differs from the profile's";
14452
+ if (typeof session.ownerDid === "string") {
14453
+ if (typeof profile?.ownerDid !== "string") return "the session names an owner the profile does not record";
14454
+ if (normalizePkhIdentifier(session.ownerDid) !== normalizePkhIdentifier(profile.ownerDid)) return "the session's owner differs from the profile's";
14455
+ }
14456
+ return void 0;
14457
+ }
14458
+ function assertSessionReplaceable(profileName, snapshot, ownerDid, scope, newExpiresAt) {
14459
+ const problem = inconsistency(snapshot);
14460
+ if (problem !== void 0) {
14461
+ throw new CLIError(
14462
+ "PROFILE_STATE_INCONSISTENT",
14463
+ `Profile "${profileName}" is inconsistent (${problem}), possibly from an interrupted write. Nothing was saved. Check \`tc --profile ${profileName} context\`, then pass --replace-session to replace this state, or use a new profile.`,
14464
+ ExitCode.ERROR
14465
+ );
14466
+ }
14467
+ const { session } = snapshot;
14468
+ if (session === null) return;
14469
+ const expiresAt = sessionExpiresAt(session);
14470
+ if (expiresAt !== null && Date.parse(expiresAt) <= Date.now()) return;
14471
+ const keepsScope = ownerDid !== void 0 && session.permissionsSource === SIGNED_RECAP && Array.isArray(session.permissions) && scopeCovers(scope, session.permissions, ownerDid);
14472
+ const shortens = newExpiresAt !== void 0 && expiresAt !== null && Date.parse(newExpiresAt) < Date.parse(expiresAt) - CLOCK_SKEW_MS;
14473
+ if (keepsScope && !shortens) return;
14474
+ const space = typeof session.spaceId === "string" ? session.spaceId : "an unknown space";
14475
+ throw new CLIError(
14476
+ "SESSION_IN_USE",
14477
+ `Profile "${profileName}" has a live session for ${space}${expiresAt ? ` until ${expiresAt}` : ""} that this login would ${keepsScope ? "shorten" : "narrow or replace"}, dropping that authority. Nothing was saved. Keep the user's existing profiles: use a new profile name (\`tc init --name publisher --key-only\`, then \`tc --profile publisher auth login --device --manifest ...\`), or pass --replace-session to replace this session.`,
14478
+ ExitCode.USAGE_ERROR
14479
+ );
14480
+ }
14481
+ var COMMIT_LOCK_TIMEOUT_MS = 45e3;
14482
+ async function restore(profileName, state) {
14483
+ const writes = [
14484
+ () => state.key === null ? ProfileManager.removeKey(profileName) : ProfileManager.setKey(profileName, state.key),
14485
+ () => state.session === null ? ProfileManager.clearSession(profileName) : ProfileManager.setSession(profileName, state.session),
14486
+ () => state.profile === null ? ProfileManager.removeProfileConfig(profileName) : ProfileManager.setProfile(profileName, state.profile)
14487
+ ];
14488
+ const failures = [];
14489
+ for (const write of writes) {
14490
+ try {
14491
+ await write();
14492
+ } catch (error) {
14493
+ failures.push(error);
14494
+ }
14495
+ }
14496
+ return failures;
14497
+ }
14498
+ function errorMessage(error) {
14499
+ return error instanceof Error ? error.message : String(error);
14500
+ }
14501
+ async function commitLogin(profileName, snapshot, commit) {
14502
+ await ProfileManager.withLock(profileName, async () => {
14503
+ const current = await readProfileSnapshot(profileName);
14504
+ if (canonicalJson(current.profile) !== canonicalJson(snapshot.profile) || canonicalJson(current.key) !== canonicalJson(snapshot.key) || canonicalJson(current.session) !== canonicalJson(snapshot.session)) {
14505
+ throw new CLIError(
14506
+ "PROFILE_CHANGED_DURING_LOGIN",
14507
+ `Profile "${profileName}" changed while waiting for approval (another login, key rotation or logout). Nothing was saved; check \`tc --profile ${profileName} context\` and run the login again if it is still needed.`,
14508
+ ExitCode.ERROR
14509
+ );
14510
+ }
14511
+ if (commit.approved && !commit.approved.replaceSession) {
14512
+ const newExpiresAt = sessionExpiresAt(commit.session) ?? void 0;
14513
+ assertSessionReplaceable(profileName, current, commit.approved.ownerDid, commit.approved.scope, newExpiresAt);
14514
+ }
14515
+ try {
14516
+ await ProfileManager.setKey(profileName, commit.key);
14517
+ await ProfileManager.setSession(profileName, commit.session);
14518
+ await ProfileManager.setProfile(profileName, commit.profile);
14519
+ } catch (error) {
14520
+ const failures = await restore(profileName, current);
14521
+ if (failures.length === 0) throw error;
14522
+ throw new CLIError(
14523
+ "PROFILE_STATE_INCONSISTENT",
14524
+ `Saving the login for profile "${profileName}" failed (${errorMessage(error)}), and restoring its previous state failed too (${failures.map(errorMessage).join("; ")}). The profile's key, session and settings may not match. Check \`tc --profile ${profileName} context\`, then run the login again with --replace-session, or use a new profile.`,
14525
+ ExitCode.ERROR
14526
+ );
14527
+ }
14528
+ }, { timeoutMs: COMMIT_LOCK_TIMEOUT_MS }).catch((error) => {
14529
+ if (!(error instanceof ProfileLockTimeoutError2)) throw error;
14530
+ throw new CLIError(
14531
+ "PROFILE_LOCK_TIMEOUT",
14532
+ `Another tc process kept profile "${profileName}" locked for ${COMMIT_LOCK_TIMEOUT_MS / 1e3} s, so the approved login was not saved. Wait for it to finish (a crashed process's lock is reclaimed after 30 s) and run the login again.`,
14533
+ ExitCode.ERROR
14534
+ );
14535
+ });
14536
+ }
14537
+
14087
14538
  // src/auth/device-auth.ts
14088
- var SHARE_DEVICE_DELEGATION_SECONDS = 30 * 24 * 60 * 60;
14089
- var SHARE_DEVICE_PERMISSIONS = [{
14090
- service: "tinycloud.capabilities",
14091
- space: "applications",
14092
- path: "",
14093
- actions: ["tinycloud.capabilities/read"]
14094
- }];
14539
+ var DEVICE_DELEGATION_MAX_SECONDS = 30 * 24 * 60 * 60;
14540
+ var DEVICE_APPROVAL_WINDOW_MAX_SECONDS = 60 * 60;
14541
+ var DEVICE_REASON_MAX_LENGTH = 200;
14542
+ function resolveDeviceApiHost(profile) {
14543
+ return process.env.TC_OPENKEY_HOST ?? profile?.openkeyHost;
14544
+ }
14095
14545
  function digest(value) {
14096
14546
  return createHash("sha256").update(value).digest("base64url");
14097
14547
  }
@@ -14107,25 +14557,42 @@ function jsonEqual(left, right) {
14107
14557
  const canonical = (value) => Array.isArray(value) ? value.map(canonical) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, canonical(entry)])) : value;
14108
14558
  return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
14109
14559
  }
14110
- function validateStart(value) {
14111
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("OpenKey returned an invalid device authorization response");
14560
+ function invalidResponse(message) {
14561
+ return new CLIError("DEVICE_AUTH_INVALID_RESPONSE", message, ExitCode.ERROR);
14562
+ }
14563
+ function verificationOrigins(openkeyHost) {
14564
+ const api = new URL(openkeyHost);
14565
+ const site = new URL(openkeyHost);
14566
+ if (site.hostname.startsWith("api.")) site.hostname = site.hostname.slice("api.".length);
14567
+ return /* @__PURE__ */ new Set([api.origin, site.origin]);
14568
+ }
14569
+ function validateStart(value, openkeyHost) {
14570
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw invalidResponse("OpenKey returned an invalid device authorization response");
14112
14571
  const result = value;
14113
- if (typeof result.transactionId !== "string" || !/^[A-Za-z0-9_-]{20,}$/.test(result.transactionId) || typeof result.userCode !== "string" || !/^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(result.userCode) || typeof result.verificationUri !== "string" || typeof result.verificationUriComplete !== "string" || !Number.isSafeInteger(result.expiresIn) || Number(result.expiresIn) < 60 || !Number.isSafeInteger(result.interval) || Number(result.interval) < 1) throw new Error("OpenKey returned an invalid device authorization response");
14572
+ if (typeof result.transactionId !== "string" || !/^[A-Za-z0-9_-]{20,}$/.test(result.transactionId) || typeof result.userCode !== "string" || !/^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(result.userCode) || typeof result.verificationUri !== "string" || result.verificationUriComplete !== void 0 && typeof result.verificationUriComplete !== "string" || !Number.isSafeInteger(result.expiresIn) || Number(result.expiresIn) < 60 || Number(result.expiresIn) > DEVICE_APPROVAL_WINDOW_MAX_SECONDS || !Number.isSafeInteger(result.interval) || Number(result.interval) < 1) throw invalidResponse("OpenKey returned an invalid device authorization response");
14114
14573
  canonicalOrigin(new URL(result.verificationUri).origin, "verification URI");
14115
- if (new URL(result.verificationUriComplete).origin !== new URL(result.verificationUri).origin) {
14116
- throw new Error("OpenKey returned an invalid verification URI");
14574
+ if (!verificationOrigins(openkeyHost).has(new URL(result.verificationUri).origin)) {
14575
+ throw invalidResponse("OpenKey returned a verification URI outside its own site");
14576
+ }
14577
+ if (typeof result.verificationUriComplete === "string" && new URL(result.verificationUriComplete).origin !== new URL(result.verificationUri).origin) {
14578
+ throw invalidResponse("OpenKey returned an invalid verification URI");
14117
14579
  }
14118
14580
  return result;
14119
14581
  }
14120
14582
  async function responseJson(response) {
14121
14583
  try {
14122
- return await response.json();
14584
+ const value = await response.json();
14585
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
14123
14586
  } catch {
14124
- throw new Error(`OpenKey device authorization failed (HTTP ${response.status})`);
14587
+ return void 0;
14125
14588
  }
14126
14589
  }
14127
14590
  function errorCode(value) {
14128
- return value && typeof value === "object" && typeof value.error === "string" ? value.error : void 0;
14591
+ return typeof value?.error === "string" ? value.error : void 0;
14592
+ }
14593
+ function errorDescription(value) {
14594
+ const description = value?.errorDescription ?? value?.error_description;
14595
+ return typeof description === "string" && description.length > 0 ? description : void 0;
14129
14596
  }
14130
14597
  function publicSessionJwk(value) {
14131
14598
  const publicJwk = publicJwkForDelegation(value);
@@ -14136,30 +14603,51 @@ function publicSessionJwk(value) {
14136
14603
  return publicJwk;
14137
14604
  }
14138
14605
  function publicRelayJwk(value) {
14139
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("OpenKey returned an invalid relay key");
14606
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw invalidResponse("OpenKey returned an invalid relay key");
14140
14607
  const jwk = value;
14141
- if (jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.x !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(jwk.x) || typeof jwk.y !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(jwk.y) || "d" in jwk) throw new Error("OpenKey returned an invalid relay key");
14608
+ if (jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.x !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(jwk.x) || typeof jwk.y !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(jwk.y) || "d" in jwk) throw invalidResponse("OpenKey returned an invalid relay key");
14142
14609
  return { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y };
14143
14610
  }
14144
14611
  function decodeCanonicalBase64Url(value, label) {
14145
- if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error(`OpenKey returned an invalid ${label}`);
14612
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/.test(value)) throw invalidResponse(`OpenKey returned an invalid ${label}`);
14146
14613
  const decoded = Buffer.from(value, "base64url");
14147
- if (decoded.toString("base64url") !== value) throw new Error(`OpenKey returned an invalid ${label}`);
14614
+ if (decoded.toString("base64url") !== value) throw invalidResponse(`OpenKey returned an invalid ${label}`);
14148
14615
  return decoded;
14149
14616
  }
14150
14617
  function deriveRelayKey(sharedSecret, transactionId) {
14151
14618
  const extracted = createHmac("sha256", Buffer.from(transactionId)).update(sharedSecret).digest();
14152
14619
  return createHmac("sha256", extracted).update(Buffer.from("openkey-device-relay-v1")).update(Buffer.from([1])).digest();
14153
14620
  }
14154
- function decryptRelayResult(envelope, transactionId, privateKey) {
14155
- if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) throw new Error("OpenKey returned an invalid encrypted relay result");
14621
+ var P256_P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
14622
+ var P256_B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
14623
+ function onP256(x, y) {
14624
+ const px = BigInt(`0x${x.toString("hex")}`);
14625
+ const py = BigInt(`0x${y.toString("hex")}`);
14626
+ if (px >= P256_P || py >= P256_P) return false;
14627
+ const mod2 = (value) => (value % P256_P + P256_P) % P256_P;
14628
+ return mod2(py * py) === mod2(px * px * px - 3n * px + P256_B);
14629
+ }
14630
+ function relayPublicJwkOf(relayKey) {
14631
+ const point = relayKey.getPublicKey();
14632
+ return publicRelayJwk({ kty: "EC", crv: "P-256", x: point.subarray(1, 33).toString("base64url"), y: point.subarray(33).toString("base64url") });
14633
+ }
14634
+ function decryptRelayResult(envelope, transactionId, relayKey) {
14635
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) throw invalidResponse("OpenKey returned an invalid encrypted relay result");
14156
14636
  const relay = envelope;
14157
- if (relay.version !== 1 || relay.algorithm !== "ECDH-P256-A256GCM") throw new Error("OpenKey returned an unsupported encrypted relay result");
14158
- const peer = createPublicKey({ key: publicRelayJwk(relay.ephemeralPublicJwk), format: "jwk" });
14637
+ if (relay.version !== 1 || relay.algorithm !== "ECDH-P256-A256GCM") throw invalidResponse("OpenKey returned an unsupported encrypted relay result");
14638
+ const peerJwk = publicRelayJwk(relay.ephemeralPublicJwk);
14639
+ const x = decodeCanonicalBase64Url(peerJwk.x, "relay key");
14640
+ const y = decodeCanonicalBase64Url(peerJwk.y, "relay key");
14641
+ if (x.length !== 32 || y.length !== 32 || !onP256(x, y)) throw invalidResponse("OpenKey returned an invalid relay key");
14159
14642
  const nonce = decodeCanonicalBase64Url(relay.nonce, "relay nonce");
14160
14643
  const ciphertext = decodeCanonicalBase64Url(relay.ciphertext, "relay ciphertext");
14161
- if (nonce.length !== 12 || ciphertext.length <= 16) throw new Error("OpenKey returned an invalid encrypted relay result");
14162
- const sharedSecret = diffieHellman({ privateKey, publicKey: peer });
14644
+ if (nonce.length !== 12 || ciphertext.length <= 16) throw invalidResponse("OpenKey returned an invalid encrypted relay result");
14645
+ let sharedSecret;
14646
+ try {
14647
+ sharedSecret = relayKey.computeSecret(Buffer.concat([Buffer.from([4]), x, y]));
14648
+ } catch {
14649
+ throw invalidResponse("OpenKey returned an invalid relay key");
14650
+ }
14163
14651
  const key = deriveRelayKey(sharedSecret, transactionId);
14164
14652
  const decipher = createDecipheriv("aes-256-gcm", key, nonce);
14165
14653
  decipher.setAAD(Buffer.from(transactionId));
@@ -14168,109 +14656,198 @@ function decryptRelayResult(envelope, transactionId, privateKey) {
14168
14656
  try {
14169
14657
  value = JSON.parse(Buffer.concat([decipher.update(ciphertext.subarray(0, -16)), decipher.final()]).toString("utf8"));
14170
14658
  } catch {
14171
- throw new Error("OpenKey returned an unreadable encrypted relay result");
14659
+ throw invalidResponse("OpenKey returned an unreadable encrypted relay result");
14172
14660
  }
14173
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("OpenKey returned an invalid encrypted relay result");
14661
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw invalidResponse("OpenKey returned an invalid encrypted relay result");
14174
14662
  return value;
14175
14663
  }
14176
- function assertShareDelegationPermissions(value) {
14177
- if (!Array.isArray(value) || value.length !== 1) throw new Error("OpenKey returned a delegation outside the requested Share scope");
14178
- const permission = value[0];
14179
- if (!permission || permission.service !== "tinycloud.capabilities" && permission.service !== "capabilities" || permission.space !== "applications" && !(typeof permission.space === "string" && permission.space.endsWith(":applications")) || permission.path !== "" || !Array.isArray(permission.actions) || permission.actions.length !== 1 || permission.actions[0] !== "tinycloud.capabilities/read") throw new Error("OpenKey returned a delegation outside the requested Share scope");
14664
+ function permissionList(value, label) {
14665
+ const valid = Array.isArray(value) && value.length > 0 && value.every((entry) => entry !== null && typeof entry === "object" && typeof entry.service === "string" && typeof entry.space === "string" && typeof entry.path === "string" && Array.isArray(entry.actions) && entry.actions.length > 0 && entry.actions.every((action) => typeof action === "string" && action.length > 0));
14666
+ if (!valid) {
14667
+ throw new CLIError("DEVICE_AUTH_BINDING_MISMATCH", `OpenKey returned no valid ${label} permissions. No session was saved.`, ExitCode.PERMISSION_DENIED);
14668
+ }
14669
+ return value;
14670
+ }
14671
+ function sameTuples(left, right) {
14672
+ return left.size === right.size && [...left].every((tuple) => right.has(tuple));
14673
+ }
14674
+ function delegationExpiry(delegation) {
14675
+ const value = delegation.expiresAt ?? delegation.expirationTime ?? delegation.expiry;
14676
+ return typeof value === "string" ? Date.parse(value) : Number.NaN;
14180
14677
  }
14181
- function assertApprovedBinding(input) {
14182
- if (input.binding.transactionId !== input.transactionId || input.binding.sessionDid !== input.sessionDid || input.binding.nodeOrigin !== input.nodeOrigin || input.binding.shareOrigin !== input.shareOrigin || !jsonEqual(input.binding.permissions, SHARE_DEVICE_PERMISSIONS)) throw new Error("OpenKey returned a delegation with the wrong device binding");
14183
- const expiresAt = Date.parse(input.binding.delegationExpiresAt);
14184
- if (!Number.isFinite(expiresAt) || expiresAt <= Date.now() || expiresAt > Date.now() + SHARE_DEVICE_DELEGATION_SECONDS * 1e3 + 3e4) {
14185
- throw new Error("OpenKey returned a delegation outside the requested expiry window");
14678
+ async function verifyApproval(input) {
14679
+ const { binding, delegation } = input;
14680
+ if (binding.transactionId !== input.transactionId || binding.sessionDid !== input.sessionDid || binding.nodeOrigin !== input.nodeOrigin || binding.shareOrigin !== input.shareOrigin) throw new CLIError("DEVICE_AUTH_BINDING_MISMATCH", "OpenKey returned a delegation with the wrong device binding. No session was saved.", ExitCode.PERMISSION_DENIED);
14681
+ const expiresAt = Date.parse(binding.delegationExpiresAt);
14682
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now() || expiresAt > expiryLimit(input.expiry)) {
14683
+ throw new CLIError("DEVICE_AUTH_BINDING_MISMATCH", "OpenKey returned a delegation outside the requested expiry window. No session was saved.", ExitCode.PERMISSION_DENIED);
14186
14684
  }
14187
- if (input.delegation.verificationMethod !== input.sessionDid) {
14188
- throw new Error("OpenKey returned a delegation for a different CLI session DID");
14685
+ if (delegationExpiry(delegation) !== expiresAt) {
14686
+ throw new CLIError("DEVICE_AUTH_BINDING_MISMATCH", "OpenKey returned a delegation outside the approved expiry window. No session was saved.", ExitCode.PERMISSION_DENIED);
14189
14687
  }
14190
- assertShareDelegationPermissions(input.delegation.permissions);
14191
- const delegationExpiryValue = input.delegation.expiresAt ?? input.delegation.expirationTime ?? input.delegation.expiry;
14192
- const delegationExpiresAt = typeof delegationExpiryValue === "string" ? Date.parse(delegationExpiryValue) : Number.NaN;
14193
- if (!Number.isFinite(delegationExpiresAt) || delegationExpiresAt !== expiresAt) {
14194
- throw new Error("OpenKey returned a delegation outside the approved expiry window");
14688
+ if (delegation.verificationMethod !== input.sessionDid) {
14689
+ throw new CLIError("DEVICE_AUTH_BINDING_MISMATCH", "OpenKey returned a delegation for a different CLI session DID. No session was saved.", ExitCode.PERMISSION_DENIED);
14195
14690
  }
14196
- if (!input.delegation.jwk || typeof input.delegation.jwk !== "object" || !jsonEqual(publicSessionJwk(input.delegation.jwk), input.publicJwk)) {
14197
- throw new Error("OpenKey returned a delegation for a different CLI session key");
14691
+ if (!delegation.jwk || typeof delegation.jwk !== "object" || !jsonEqual(publicSessionJwk(delegation.jwk), input.publicJwk)) {
14692
+ throw new CLIError("DEVICE_AUTH_BINDING_MISMATCH", "OpenKey returned a delegation for a different CLI session key. No session was saved.", ExitCode.PERMISSION_DENIED);
14198
14693
  }
14199
- const invalid = validateDelegationCallbackPayload(input.delegation);
14200
- if (invalid) throw new Error(`OpenKey returned an invalid delegation: ${invalid}`);
14694
+ const invalid = validateDelegationCallbackPayload(delegation);
14695
+ if (invalid) throw invalidResponse(`OpenKey returned an invalid delegation: ${invalid}`);
14696
+ const session = verifyScopedLogin(delegation, input.key, input.sessionDid, input.requested, {
14697
+ expectedOwner: input.expectedOwner,
14698
+ expiry: input.expiry
14699
+ });
14700
+ const { ownerDid } = session;
14701
+ if (Math.abs(Date.parse(session.expiresAt) - expiresAt) > 1e3) {
14702
+ throw new CLIError("DEVICE_AUTH_BINDING_MISMATCH", "The signed session expiry differs from the approved expiry. No session was saved.", ExitCode.PERMISSION_DENIED);
14703
+ }
14704
+ const signed = permissionTuples(session.permissions, ownerDid);
14705
+ if (!sameTuples(signed, permissionTuples(permissionList(binding.permissions, "approved"), ownerDid)) || !sameTuples(signed, permissionTuples(permissionList(delegation.permissions, "delegated"), ownerDid))) {
14706
+ throw new CLIError("DEVICE_AUTH_BINDING_MISMATCH", "OpenKey's approved permissions differ from the signed grant. No session was saved.", ExitCode.PERMISSION_DENIED);
14707
+ }
14708
+ const requested = permissionTuples(input.requested, ownerDid);
14709
+ return {
14710
+ session,
14711
+ ownerDid,
14712
+ spaceId: delegation.spaceId,
14713
+ expiresAt: session.expiresAt,
14714
+ approved: permissionsFromTuples(signed),
14715
+ declined: permissionsFromTuples([...requested].filter((tuple) => !signed.has(tuple)))
14716
+ };
14201
14717
  }
14202
- async function acquireShareDeviceDelegation(input) {
14718
+ function writeApprovalPrompt(prompt) {
14719
+ const link2 = prompt.verificationUriComplete ?? prompt.verificationUri;
14720
+ process.stderr.write(
14721
+ `Approve on your phone: ${link2} (code ${prompt.userCode})
14722
+ Or open ${prompt.verificationUri} and enter code ${prompt.userCode}.
14723
+ Waiting for approval until ${prompt.expiresAt}. Keep this command running.
14724
+ `
14725
+ );
14726
+ }
14727
+ async function acquireDeviceDelegation(input) {
14728
+ validateLoginPermissions(input.permissions);
14729
+ const expiry = input.expiry ?? { durationMs: DEVICE_DELEGATION_MAX_SECONDS * 1e3 };
14730
+ const ttlSeconds = Math.floor(expiry.durationMs / 1e3);
14731
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 60 || ttlSeconds > DEVICE_DELEGATION_MAX_SECONDS) {
14732
+ throw new CLIError("INVALID_EXPIRY", "Device authorization --expiry must be between 1 minute and 30 days.", ExitCode.USAGE_ERROR);
14733
+ }
14734
+ const reason = input.reason?.trim().slice(0, DEVICE_REASON_MAX_LENGTH);
14203
14735
  const openkeyHost = canonicalOrigin(input.openkeyHost ?? DEFAULT_OPENKEY_DEVICE_API_HOST, "OpenKey host");
14204
14736
  const nodeOrigin = canonicalOrigin(input.nodeOrigin, "TinyCloud node origin");
14205
14737
  const shareOrigin = canonicalOrigin(input.shareOrigin, "Share origin");
14206
14738
  const fetchFn = input.fetchFn ?? globalThis.fetch;
14207
14739
  const deviceSecret = randomBytes3(32).toString("base64url");
14208
14740
  const codeVerifier = randomBytes3(32).toString("base64url");
14209
- const relayKeys = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
14210
- const relayPublicJwk = publicRelayJwk(relayKeys.publicKey.export({ format: "jwk" }));
14741
+ const relayKey = createECDH("prime256v1");
14742
+ relayKey.generateKeys();
14743
+ const relayPublicJwk = relayPublicJwkOf(relayKey);
14211
14744
  const publicJwk = publicSessionJwk(input.jwk);
14212
- const startResponse = await fetchFn(`${openkeyHost}/api/device-authorizations`, {
14213
- method: "POST",
14214
- credentials: "omit",
14215
- redirect: "error",
14216
- referrerPolicy: "no-referrer",
14217
- headers: { accept: "application/json", "content-type": "application/json" },
14218
- body: JSON.stringify({
14219
- deviceSecretHash: digest(deviceSecret),
14220
- codeChallenge: digest(codeVerifier),
14221
- relayPublicJwk,
14222
- sessionDid: input.sessionDid,
14223
- publicJwk,
14224
- permissions: SHARE_DEVICE_PERMISSIONS,
14225
- nodeOrigin,
14226
- shareOrigin,
14227
- delegationTtlSeconds: SHARE_DEVICE_DELEGATION_SECONDS
14228
- })
14229
- });
14230
- const startValue = await responseJson(startResponse);
14231
- if (!startResponse.ok) throw new Error(`OpenKey device authorization failed: ${errorCode(startValue) ?? startResponse.status}`);
14232
- const started = validateStart(startValue);
14233
- (input.emitInstructions ?? ((value) => {
14234
- process.stderr.write(`OpenKey device authorization
14235
- Visit: ${value.verificationUri}
14236
- Code: ${value.userCode}
14237
-
14238
- Waiting for approval\u2026
14239
- `);
14240
- }))({ verificationUri: started.verificationUri, verificationUriComplete: started.verificationUriComplete, userCode: started.userCode });
14241
- const deadline = Date.now() + started.expiresIn * 1e3;
14242
- let interval = started.interval;
14243
- const wait = input.wait ?? ((milliseconds) => new Promise((resolve4) => setTimeout(resolve4, milliseconds)));
14244
- while (Date.now() < deadline) {
14245
- await wait(interval * 1e3);
14246
- const response = await fetchFn(`${openkeyHost}/api/device-authorizations/token`, {
14745
+ let startResponse;
14746
+ try {
14747
+ startResponse = await fetchFn(`${openkeyHost}/api/device-authorizations`, {
14247
14748
  method: "POST",
14248
14749
  credentials: "omit",
14249
14750
  redirect: "error",
14250
14751
  referrerPolicy: "no-referrer",
14251
14752
  headers: { accept: "application/json", "content-type": "application/json" },
14252
- body: JSON.stringify({ transactionId: started.transactionId, deviceSecret, codeVerifier })
14753
+ body: JSON.stringify({
14754
+ deviceSecretHash: digest(deviceSecret),
14755
+ codeChallenge: digest(codeVerifier),
14756
+ relayPublicJwk,
14757
+ sessionDid: input.sessionDid,
14758
+ publicJwk,
14759
+ permissions: input.permissions.map(({ service, space, path, actions }) => ({ service, space, path, actions })),
14760
+ nodeOrigin,
14761
+ shareOrigin,
14762
+ delegationTtlSeconds: ttlSeconds,
14763
+ ...reason ? { reason } : {}
14764
+ })
14253
14765
  });
14766
+ } catch (error) {
14767
+ throw new CLIError(
14768
+ "OPENKEY_UNREACHABLE",
14769
+ `Could not reach the OpenKey device API at ${openkeyHost}: ${error instanceof Error ? error.message : String(error)}. Check TC_OPENKEY_HOST or the profile's openkeyHost.`,
14770
+ ExitCode.NETWORK_ERROR
14771
+ );
14772
+ }
14773
+ const startValue = await responseJson(startResponse);
14774
+ if (!startResponse.ok) {
14775
+ const code2 = errorCode(startValue);
14776
+ const description = errorDescription(startValue);
14777
+ if (code2 === "invalid_scope") {
14778
+ throw new CLIError(
14779
+ "SCOPE_REJECTED",
14780
+ `OpenKey rejected the requested scope${description ? `: ${description}` : "."} Remove that capability from the manifest or use another approval flow.`,
14781
+ ExitCode.PERMISSION_DENIED,
14782
+ { openkeyError: code2, ...description ? { capability: description } : {} }
14783
+ );
14784
+ }
14785
+ throw new CLIError("DEVICE_AUTH_FAILED", `OpenKey device authorization failed: ${code2 ?? `HTTP ${startResponse.status}`}${description ? ` (${description})` : ""}`, ExitCode.ERROR);
14786
+ }
14787
+ const started = validateStart(startValue, openkeyHost);
14788
+ const deadline = Date.now() + started.expiresIn * 1e3;
14789
+ (input.emitInstructions ?? writeApprovalPrompt)({
14790
+ verificationUri: started.verificationUri,
14791
+ ...started.verificationUriComplete ? { verificationUriComplete: started.verificationUriComplete } : {},
14792
+ userCode: started.userCode,
14793
+ expiresAt: new Date(deadline).toISOString().replace(/\.\d{3}Z$/, "Z")
14794
+ });
14795
+ let interval = started.interval;
14796
+ const wait = input.wait ?? ((milliseconds) => new Promise((resolve4) => setTimeout(resolve4, milliseconds)));
14797
+ while (Date.now() < deadline) {
14798
+ await wait(interval * 1e3);
14799
+ let response;
14800
+ try {
14801
+ response = await fetchFn(`${openkeyHost}/api/device-authorizations/token`, {
14802
+ method: "POST",
14803
+ credentials: "omit",
14804
+ redirect: "error",
14805
+ referrerPolicy: "no-referrer",
14806
+ headers: { accept: "application/json", "content-type": "application/json" },
14807
+ body: JSON.stringify({ transactionId: started.transactionId, deviceSecret, codeVerifier })
14808
+ });
14809
+ } catch {
14810
+ continue;
14811
+ }
14254
14812
  const value = await responseJson(response);
14255
14813
  const code2 = errorCode(value);
14256
- if (response.status === 429 && code2 === "slow_down") {
14257
- interval += 1;
14814
+ if (response.status >= 500 || response.status === 429 && code2 !== "slow_down") continue;
14815
+ if (code2 === "slow_down") {
14816
+ interval += 5;
14258
14817
  continue;
14259
14818
  }
14260
- if (!response.ok) throw new Error(`OpenKey device authorization failed: ${code2 ?? response.status}`);
14819
+ if (code2 === "authorization_pending") continue;
14820
+ if (code2 === "access_denied") {
14821
+ throw new CLIError("DEVICE_AUTH_DENIED", "The owner denied the OpenKey device authorization. No session was saved.", ExitCode.PERMISSION_DENIED);
14822
+ }
14823
+ if (code2 === "expired_token") {
14824
+ throw new CLIError("DEVICE_AUTH_EXPIRED", "OpenKey device authorization expired_token: the approval window closed. Run the command again.", ExitCode.AUTH_REQUIRED);
14825
+ }
14826
+ if (!response.ok) throw new CLIError("DEVICE_AUTH_FAILED", `OpenKey device authorization failed: ${code2 ?? `HTTP ${response.status}`}`, ExitCode.ERROR);
14261
14827
  const result = value;
14262
- if (result.status === "pending") {
14263
- interval = Math.max(interval, result.interval);
14828
+ if (result?.status === "pending") {
14829
+ interval = Math.max(interval, Number.isSafeInteger(result.interval) ? result.interval : interval);
14264
14830
  continue;
14265
14831
  }
14266
- if (result.status !== "approved" || !result.relay || !result.binding) {
14267
- throw new Error("OpenKey returned an invalid device authorization result");
14832
+ if (result?.status !== "approved" || !result.relay || !result.binding) {
14833
+ throw invalidResponse("OpenKey returned an invalid device authorization result");
14268
14834
  }
14269
- const delegation = decryptRelayResult(result.relay, started.transactionId, relayKeys.privateKey);
14270
- assertApprovedBinding({ binding: result.binding, transactionId: started.transactionId, sessionDid: input.sessionDid, nodeOrigin, shareOrigin, publicJwk, delegation });
14271
- return delegation;
14835
+ const delegation = decryptRelayResult(result.relay, started.transactionId, relayKey);
14836
+ return verifyApproval({
14837
+ binding: result.binding,
14838
+ delegation,
14839
+ transactionId: started.transactionId,
14840
+ sessionDid: input.sessionDid,
14841
+ nodeOrigin,
14842
+ shareOrigin,
14843
+ publicJwk,
14844
+ key: input.jwk,
14845
+ requested: input.permissions,
14846
+ expiry,
14847
+ expectedOwner: input.expectedOwner
14848
+ });
14272
14849
  }
14273
- throw new Error("OpenKey device authorization expired before approval");
14850
+ throw new CLIError("DEVICE_AUTH_EXPIRED", "OpenKey device authorization expired before approval. Run the command again.", ExitCode.AUTH_REQUIRED);
14274
14851
  }
14275
14852
  function mergePrivateJwkIntoSession(session, key) {
14276
14853
  const sessionJwk = session.jwk;
@@ -14281,56 +14858,52 @@ function mergePrivateJwkIntoSession(session, key) {
14281
14858
  if (typeof privateParameter !== "string" || privateParameter.length === 0) return session;
14282
14859
  return { ...session, jwk: { ...sessionJwkRecord, d: privateParameter } };
14283
14860
  }
14284
- async function ensureShareDeviceAuthorization(input) {
14285
- let profile = await ProfileManager.getProfile(input.profileName).catch(() => null);
14286
- if (profile?.authMethod === "local" && input.allowReplaceLocal !== true) {
14287
- throw new Error("This profile uses a local owner key. Run `tc auth login --device` explicitly to replace its authentication posture.");
14288
- }
14289
- let key = await ProfileManager.getKey(input.profileName);
14290
- if (!key) {
14291
- const generated = generateKey();
14292
- key = generated.jwk;
14293
- await ProfileManager.setKey(input.profileName, key);
14861
+ async function loginWithDeviceAuthorization(input) {
14862
+ const snapshot = await readProfileSnapshot(input.profileName);
14863
+ const existing = snapshot.profile;
14864
+ assertNotLocalOwner(input.profileName, existing, "Device login");
14865
+ const expectedOwner = expectedOwnerFor(input.profileName, existing, input.expectedOwner);
14866
+ if (input.replaceSession !== true) {
14867
+ const estimatedExpiry = new Date(Date.now() + (input.expiry?.durationMs ?? DEVICE_DELEGATION_MAX_SECONDS * 1e3)).toISOString();
14868
+ assertSessionReplaceable(input.profileName, snapshot, expectedOwner, input.permissions, estimatedExpiry);
14294
14869
  }
14870
+ const key = snapshot.key ?? generateKey().jwk;
14295
14871
  const sessionDid = keyToDID(key);
14296
- profile = {
14297
- ...profile,
14872
+ const result = await acquireDeviceDelegation({
14873
+ ...input,
14874
+ sessionDid,
14875
+ jwk: key,
14876
+ openkeyHost: input.openkeyHost ?? resolveDeviceApiHost(existing),
14877
+ expectedOwner
14878
+ });
14879
+ const profile = {
14880
+ ...existing,
14298
14881
  name: input.profileName,
14299
- host: input.nodeOrigin,
14300
- chainId: profile?.chainId ?? DEFAULT_CHAIN_ID,
14301
- spaceName: profile?.spaceName ?? "applications",
14882
+ host: input.persistHost === true || !existing?.host ? input.nodeOrigin : existing.host,
14883
+ chainId: existing?.chainId ?? DEFAULT_CHAIN_ID,
14884
+ spaceName: result.spaceId.slice(result.spaceId.lastIndexOf(":") + 1),
14302
14885
  did: sessionDid,
14303
14886
  sessionDid,
14304
- createdAt: profile?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
14887
+ ownerDid: result.ownerDid,
14888
+ spaceId: result.spaceId,
14889
+ createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
14305
14890
  posture: "owner-openkey",
14306
- operatorType: profile?.operatorType ?? "human",
14307
- authMethod: "openkey",
14308
- openkeyHost: input.openkeyHost ?? profile?.openkeyHost
14891
+ operatorType: existing?.operatorType ?? "human",
14892
+ authMethod: "openkey"
14309
14893
  };
14310
- await ProfileManager.setProfile(input.profileName, profile);
14311
- const delegation = await acquireShareDeviceDelegation({
14312
- sessionDid,
14313
- jwk: key,
14314
- nodeOrigin: input.nodeOrigin,
14315
- shareOrigin: input.shareOrigin,
14316
- openkeyHost: input.openkeyHost ?? profile.openkeyHost,
14317
- fetchFn: input.fetchFn,
14318
- emitInstructions: input.emitInstructions,
14319
- wait: input.wait
14894
+ await commitLogin(input.profileName, snapshot, {
14895
+ key,
14896
+ session: result.session,
14897
+ profile,
14898
+ // The signed permissions, with their caveats; `approved` is the action summary.
14899
+ approved: { scope: result.session.permissions, ownerDid: result.ownerDid, replaceSession: input.replaceSession === true }
14320
14900
  });
14321
- const session = mergePrivateJwkIntoSession(delegation, key);
14322
- await ProfileManager.setSession(input.profileName, session);
14323
- const updatedProfile = {
14324
- ...profile,
14325
- ownerDid: typeof session.ownerDid === "string" ? session.ownerDid : profile.ownerDid,
14326
- spaceId: typeof session.spaceId === "string" ? session.spaceId : profile.spaceId
14327
- };
14328
- await ProfileManager.setProfile(input.profileName, updatedProfile);
14329
- return { profile: updatedProfile, delegation: session };
14901
+ return { profile, result };
14330
14902
  }
14331
14903
 
14332
14904
  // src/commands/auth.ts
14333
14905
  init_theme();
14906
+ init_space();
14334
14907
  function resolveOpenKeyHost(profile) {
14335
14908
  return process.env.TC_OPENKEY_HOST ?? profile.openkeyHost ?? DEFAULT_OPENKEY_HOST;
14336
14909
  }
@@ -14362,20 +14935,60 @@ async function promptAuthMethod() {
14362
14935
  }
14363
14936
  function registerAuthCommand(program) {
14364
14937
  const auth = program.command("auth").description("Authentication management");
14365
- auth.command("login").description("Authenticate with TinyCloud").option("--device", "Use OpenKey device authorization (recommended for remote/headless use)").option("--paste", "Use manual paste mode instead of browser callback").option("--no-popup", "Print the OpenKey URL without opening a browser").option("--method <method>", "Authentication method: local or openkey").action(async (options, cmd) => {
14938
+ auth.command("login").description("Authenticate with TinyCloud").option("--device", "Approve on another device (e.g. a phone) through OpenKey device authorization; requires a KV-scoped --manifest that also requests tinycloud.capabilities/read on the space root (SQL needs browser login)").option("--paste", "Use manual paste mode instead of browser callback").option("--no-popup", "Print the OpenKey URL without opening a browser").option("--method <method>", "Authentication method: local or openkey").option("--manifest <fileOrBase64>", `Request only this manifest's permissions (one space); ${SHARE_PUBLISHING_MANIFEST_REF} covers \`tc share publish\``).option("--expiry <duration>", "OpenKey session lifetime, e.g. 1h or 7d (device login: at most 30d, default 30d)").option("--owner <did>", "Require this existing primary DID (did:pkh:eip155:CHAIN:ADDRESS) for scoped login").option("--replace-session", "Scoped or device login: replace this profile's live session even though the new scope would narrow, change or shorten it (prefer a new profile)").action(async (options, cmd) => {
14366
14939
  try {
14367
14940
  if (options.device && options.paste) {
14368
14941
  throw new CLIError("INVALID_ARGUMENT", "--device and --paste are mutually exclusive.", ExitCode.USAGE_ERROR);
14369
14942
  }
14943
+ const scoped = options.device || options.manifest || options.expiry || options.owner;
14944
+ if (scoped && options.method === "local") {
14945
+ throw new CLIError("INVALID_ARGUMENT", "--device, --manifest, --expiry and --owner require OpenKey login.", ExitCode.USAGE_ERROR);
14946
+ }
14947
+ if (options.owner && !options.manifest) {
14948
+ throw new CLIError("INVALID_ARGUMENT", "--owner requires --manifest so the signed identity is verified.", ExitCode.USAGE_ERROR);
14949
+ }
14950
+ if (options.device && !options.manifest) {
14951
+ throw new CLIError(
14952
+ "MANIFEST_REQUIRED",
14953
+ `Device login requests an explicit scope. Pass --manifest FILE, or --manifest ${SHARE_PUBLISHING_MANIFEST_REF} for Share publishing.`,
14954
+ ExitCode.USAGE_ERROR
14955
+ );
14956
+ }
14370
14957
  const globalOpts = cmd.optsWithGlobals();
14371
14958
  const ctx = await ProfileManager.resolveContext(globalOpts);
14372
- let method;
14373
- if (options.device && options.method === "local") {
14374
- throw new CLIError("INVALID_ARGUMENT", "--device requires --method openkey.", ExitCode.USAGE_ERROR);
14375
- }
14959
+ const permissions = options.manifest ? await loadManifestPermissions(options.manifest, ctx.profile, { allowLogicalSpaces: true }) : void 0;
14960
+ const persistHost = globalOpts.host !== void 0;
14376
14961
  if (options.device) {
14377
- method = "openkey";
14378
- } else if (options.method) {
14962
+ const { profile, result } = await loginWithDeviceAuthorization({
14963
+ profileName: ctx.profile,
14964
+ nodeOrigin: ctx.host,
14965
+ shareOrigin: DEFAULT_SHARE_ORIGIN,
14966
+ permissions,
14967
+ ...options.expiry === void 0 ? {} : { expiry: parseRequestedExpiry(parseExpiryOption(options.expiry)) },
14968
+ reason: "Allow this TinyCloud CLI profile to use the permissions in the requested manifest.",
14969
+ expectedOwner: options.owner,
14970
+ replaceSession: options.replaceSession === true,
14971
+ persistHost
14972
+ });
14973
+ reportDeclined(result.declined);
14974
+ outputJson({
14975
+ authenticated: true,
14976
+ profile: ctx.profile,
14977
+ did: profile.did,
14978
+ ownerDid: result.ownerDid,
14979
+ spaceId: result.spaceId,
14980
+ host: ctx.host,
14981
+ authMethod: "openkey",
14982
+ mode: "device",
14983
+ scoped: true,
14984
+ permissions: result.approved,
14985
+ declined: result.declined,
14986
+ expiresAt: result.expiresAt
14987
+ });
14988
+ return;
14989
+ }
14990
+ let method;
14991
+ if (options.method) {
14379
14992
  if (options.method !== "local" && options.method !== "openkey") {
14380
14993
  throw new CLIError(
14381
14994
  "INVALID_METHOD",
@@ -14385,7 +14998,14 @@ function registerAuthCommand(program) {
14385
14998
  }
14386
14999
  method = options.method;
14387
15000
  } else {
14388
- method = await promptAuthMethod();
15001
+ method = scoped ? "openkey" : await promptAuthMethod();
15002
+ }
15003
+ if (method === "openkey" && !options.paste && options.popup !== false && !isInteractive()) {
15004
+ throw new CLIError(
15005
+ "INTERACTIVE_LOGIN_REQUIRED",
15006
+ `Browser login needs a browser on this machine and would wait silently. Use \`tc auth login --device --manifest ${SHARE_PUBLISHING_MANIFEST_REF}\` (or your app's manifest) to approve on a phone, or \`--paste\` to paste a return code.`,
15007
+ ExitCode.USAGE_ERROR
15008
+ );
14389
15009
  }
14390
15010
  if (method === "local") {
14391
15011
  await handleLocalAuth(ctx.profile, ctx.host);
@@ -14393,7 +15013,11 @@ function registerAuthCommand(program) {
14393
15013
  await handleOpenKeyAuth(ctx.profile, ctx.host, {
14394
15014
  paste: options.paste,
14395
15015
  noPopup: options.popup === false,
14396
- device: options.device === true || !isInteractive() && options.paste !== true
15016
+ permissions,
15017
+ expiry: parseExpiryOption(options.expiry),
15018
+ expectedOwner: options.owner,
15019
+ replaceSession: options.replaceSession === true,
15020
+ persistHost
14397
15021
  });
14398
15022
  }
14399
15023
  } catch (error) {
@@ -14479,11 +15103,14 @@ function registerAuthCommand(program) {
14479
15103
  ).option("--permission <file>", 'JSON permission request: { "permissions": PermissionEntry[] }').option("--manifest <fileOrBase64>", "Manifest file, base64:<json>, or raw base64 JSON").option(
14480
15104
  "--expiry <duration>",
14481
15105
  `Lifetime of the granted delegation. ms-format string (e.g. "7d", "30m") or raw milliseconds. Defaults to 7d, capped by the active session's expiry.`
14482
- ).option("--emit [file]", "Emit the request artifact to stdout, or write it to file when provided").option("--grant", "Grant the requested permissions immediately with this owner profile").option("--yes", "Skip local-key TTY confirmation", false).option("--no-popup", "Print the OpenKey URL without opening a browser when granting with OpenKey").action(async (options, cmd) => {
15106
+ ).option("--emit [file]", "Emit the request artifact to stdout, or write it to file when provided").option("--grant", "Grant the requested permissions immediately with this owner profile").option("--yes", "Skip local-key TTY confirmation", false).option("--no-popup", "Print the OpenKey URL without opening a browser when granting with OpenKey").option("--device", "With --grant: approve on another device (e.g. a phone) through OpenKey device authorization (KV-scoped; SQL needs browser login)").action(async (options, cmd) => {
14483
15107
  try {
14484
15108
  const globalOpts = cmd.optsWithGlobals();
14485
15109
  const ctx = await ProfileManager.resolveContext(globalOpts);
14486
15110
  const profile = await ProfileManager.getProfile(ctx.profile);
15111
+ if (options.device && (!options.grant || resolveProfilePosture(profile) !== "owner-openkey")) {
15112
+ throw new CLIError("INVALID_ARGUMENT", "--device requires --grant on an OpenKey owner profile.", ExitCode.USAGE_ERROR);
15113
+ }
14487
15114
  const requested = await collectRequestedPermissions(options, ctx.profile);
14488
15115
  const expiryOption = parseExpiryOption(options.expiry);
14489
15116
  if (requested.length === 0) {
@@ -14510,7 +15137,7 @@ function registerAuthCommand(program) {
14510
15137
  outputJson({ changed: false, missing: [], added: [] });
14511
15138
  return;
14512
15139
  }
14513
- if (profile.authMethod === "openkey") {
15140
+ if (profile.authMethod === "openkey" || options.device) {
14514
15141
  const key = await ProfileManager.getKey(ctx.profile);
14515
15142
  if (!key) {
14516
15143
  throw new CLIError("NO_KEY", `No key found for profile "${ctx.profile}". Run \`tc init\` first.`, ExitCode.AUTH_REQUIRED);
@@ -14519,19 +15146,38 @@ function registerAuthCommand(program) {
14519
15146
  let expiry2;
14520
15147
  const openkeyHost = resolveOpenKeyHost(profile);
14521
15148
  const openkeyEffective = [];
15149
+ const declined = [];
14522
15150
  for (const group of groupPermissionsBySpace(requested)) {
14523
- const delegationData = await startAuthFlow(profile.did, {
14524
- jwk: key,
14525
- host: ctx.host,
14526
- permissions: group,
14527
- reason: permissionGrantReason(
14528
- "Grant requested TinyCloud permissions from `tc auth request --grant`.",
14529
- group
14530
- ),
14531
- openkeyHost,
14532
- expiry: expiryOption,
14533
- noPopup: options.popup === false
14534
- });
15151
+ const reason = permissionGrantReason(
15152
+ "Grant requested TinyCloud permissions from `tc auth request --grant`.",
15153
+ group
15154
+ );
15155
+ let delegationData;
15156
+ if (options.device) {
15157
+ const approval = await acquireDeviceDelegation({
15158
+ sessionDid: keyToDID(key),
15159
+ jwk: key,
15160
+ nodeOrigin: ctx.host,
15161
+ shareOrigin: DEFAULT_SHARE_ORIGIN,
15162
+ permissions: group,
15163
+ expiry: parseRequestedExpiry(expiryOption ?? "7d"),
15164
+ reason,
15165
+ expectedOwner: pinnedOwner(profile),
15166
+ openkeyHost: resolveDeviceApiHost(profile)
15167
+ });
15168
+ declined.push(...approval.declined);
15169
+ delegationData = approval.session;
15170
+ } else {
15171
+ delegationData = await startAuthFlow(profile.did, {
15172
+ jwk: key,
15173
+ host: ctx.host,
15174
+ permissions: group,
15175
+ reason,
15176
+ openkeyHost,
15177
+ expiry: expiryOption === void 0 ? void 0 : openKeyExpiryParam(parseRequestedExpiry(expiryOption)),
15178
+ noPopup: options.popup === false
15179
+ });
15180
+ }
14535
15181
  const delegation = portableFromOpenKeyDelegation(delegationData, group, ctx.host);
14536
15182
  const effective = permissionsFromDelegation(delegation);
14537
15183
  openkeyEffective.push(...effective);
@@ -14547,12 +15193,14 @@ function registerAuthCommand(program) {
14547
15193
  expiry: expiry2
14548
15194
  });
14549
15195
  }
15196
+ reportDeclined(declined);
14550
15197
  outputJson({
14551
15198
  changed: delegationCids2.length > 0,
14552
15199
  added: openkeyEffective,
14553
15200
  delegationCid: delegationCids2[0],
14554
15201
  delegationCids: delegationCids2,
14555
- expiry: expiry2
15202
+ expiry: expiry2,
15203
+ ...options.device ? { declined } : {}
14556
15204
  });
14557
15205
  return;
14558
15206
  }
@@ -14964,8 +15612,10 @@ async function importRequestBoundDelegationWithBootstrap(ctx, artifact) {
14964
15612
  await bootstrapDelegatedSession(ctx, delegation);
14965
15613
  await importRequestBoundDelegation(ctx, artifact);
14966
15614
  } catch (error) {
14967
- await ProfileManager.clearSession(ctx.profile);
14968
- await ProfileManager.setProfile(ctx.profile, profile);
15615
+ await ProfileManager.withLock(ctx.profile, async () => {
15616
+ await ProfileManager.clearSession(ctx.profile);
15617
+ await ProfileManager.setProfile(ctx.profile, profile);
15618
+ });
14969
15619
  throw error;
14970
15620
  }
14971
15621
  }
@@ -15051,7 +15701,7 @@ async function ensureDelegationAuthority(params) {
15051
15701
  permissions: group,
15052
15702
  reason: permissionGrantReason(params.reason, group),
15053
15703
  openkeyHost,
15054
- expiry: params.expiryOption
15704
+ expiry: params.expiryOption === void 0 ? void 0 : openKeyExpiryParam(parseRequestedExpiry(params.expiryOption))
15055
15705
  });
15056
15706
  const delegation = portableFromOpenKeyDelegation(delegationData, group, params.ctx.host);
15057
15707
  const effective = permissionsFromDelegation(delegation);
@@ -15193,7 +15843,7 @@ function groupPermissionsBySpace(permissions) {
15193
15843
  rawEntries.push(permission);
15194
15844
  continue;
15195
15845
  }
15196
- const key = normalizeSpaceForCompare(permission.space ?? "");
15846
+ const key = normalizePkhIdentifier(permission.space ?? "");
15197
15847
  const group = groups.get(key) ?? [];
15198
15848
  group.push(permission);
15199
15849
  groups.set(key, group);
@@ -15208,14 +15858,8 @@ function groupPermissionsBySpace(permissions) {
15208
15858
  function isRawPermission(permission) {
15209
15859
  return permission.service === "tinycloud.encryption" && permission.path.startsWith("urn:tinycloud:encryption:");
15210
15860
  }
15211
- function normalizeSpaceForCompare(space) {
15212
- return space.replace(
15213
- /(eip155:\d+:)(0x[0-9a-fA-F]{40})/,
15214
- (_match, prefix, addr) => prefix + addr.toLowerCase()
15215
- );
15216
- }
15217
15861
  function returnedSpaceMatchesExpected(returnedSpace, expectedSpace) {
15218
- if (normalizeSpaceForCompare(returnedSpace) === normalizeSpaceForCompare(expectedSpace)) {
15862
+ if (normalizePkhIdentifier(returnedSpace) === normalizePkhIdentifier(expectedSpace)) {
15219
15863
  return true;
15220
15864
  }
15221
15865
  if (!returnedSpace.startsWith("tinycloud:")) return false;
@@ -15226,7 +15870,7 @@ function portableFromOpenKeyDelegation(data, permissions, host) {
15226
15870
  const primary = permissions.find((permission) => !isRawPermission(permission)) ?? permissions[0];
15227
15871
  const returnedSpace = String(data.spaceId ?? primary.space ?? "encryption");
15228
15872
  const expectedSpaces = new Set(
15229
- permissions.filter((permission) => !isRawPermission(permission)).map((permission) => normalizeSpaceForCompare(permission.space ?? ""))
15873
+ permissions.filter((permission) => !isRawPermission(permission)).map((permission) => normalizePkhIdentifier(permission.space ?? ""))
15230
15874
  );
15231
15875
  const matchesExpectedSpace = expectedSpaces.size === 1 && returnedSpaceMatchesExpected(returnedSpace, Array.from(expectedSpaces)[0]);
15232
15876
  if (expectedSpaces.size > 0 && !matchesExpectedSpace) {
@@ -15239,7 +15883,7 @@ function portableFromOpenKeyDelegation(data, permissions, host) {
15239
15883
  const expiry = inferDelegationExpiry(data);
15240
15884
  const requestedPairs = new Set(
15241
15885
  permissions.flatMap(
15242
- (p) => isRawPermission(p) ? p.actions.map((a) => `${p.service}|${p.space ?? ""}|${p.path}|${a}`) : p.actions.map((a) => `${p.service}|${normalizeSpaceForCompare(p.space ?? "")}|${p.path}|${a}`)
15886
+ (p) => isRawPermission(p) ? p.actions.map((a) => `${p.service}|${p.space ?? ""}|${p.path}|${a}`) : p.actions.map((a) => `${p.service}|${normalizePkhIdentifier(p.space ?? "")}|${p.path}|${a}`)
15243
15887
  )
15244
15888
  );
15245
15889
  const returnedPermissions = Array.isArray(data.permissions) ? data.permissions : null;
@@ -15253,7 +15897,7 @@ function portableFromOpenKeyDelegation(data, permissions, host) {
15253
15897
  space: permSpace,
15254
15898
  path: permission.path,
15255
15899
  actions: []
15256
- }) ? permSpace : normalizeSpaceForCompare(permSpace);
15900
+ }) ? permSpace : normalizePkhIdentifier(permSpace);
15257
15901
  for (const action of permission.actions) {
15258
15902
  const key = `${rawService}|${rawSpace}|${permission.path}|${action}`;
15259
15903
  if (!requestedPairs.has(key)) {
@@ -15271,11 +15915,13 @@ function portableFromOpenKeyDelegation(data, permissions, host) {
15271
15915
  path: permission.path,
15272
15916
  actions: []
15273
15917
  }) ? permSpace : returnedSpace;
15918
+ const caveats = Array.isArray(permission.caveats) && permission.caveats.length > 0 ? permission.caveats.map((caveat) => structuredClone(caveat)) : void 0;
15274
15919
  return {
15275
15920
  service,
15276
15921
  space: resolvedSpace,
15277
15922
  path: permission.path,
15278
- actions: [...permission.actions]
15923
+ actions: [...permission.actions],
15924
+ ...caveats === void 0 ? {} : { caveats }
15279
15925
  };
15280
15926
  });
15281
15927
  return {
@@ -15352,16 +15998,19 @@ async function rotateAuthKey(profileName, host, options = {}) {
15352
15998
  const { jwk, did } = await withSpinner("Generating session key...", async () => {
15353
15999
  return generateKey();
15354
16000
  });
15355
- await ProfileManager.setKey(profileName, jwk);
15356
- await ProfileManager.clearSession(profileName);
15357
- await ProfileManager.setProfile(profileName, {
15358
- ...profile,
15359
- host,
15360
- did,
15361
- sessionDid: did,
15362
- posture: profile.posture ?? "owner-openkey",
15363
- operatorType: profile.operatorType ?? "human",
15364
- authMethod: "openkey"
16001
+ await ProfileManager.withLock(profileName, async () => {
16002
+ const current = await ProfileManager.getProfile(profileName);
16003
+ await ProfileManager.setKey(profileName, jwk);
16004
+ await ProfileManager.clearSession(profileName);
16005
+ await ProfileManager.setProfile(profileName, {
16006
+ ...current,
16007
+ host,
16008
+ did,
16009
+ sessionDid: did,
16010
+ posture: current.posture ?? "owner-openkey",
16011
+ operatorType: current.operatorType ?? "human",
16012
+ authMethod: "openkey"
16013
+ });
15365
16014
  });
15366
16015
  const result = await refreshOpenKeySession(profileName, host, {
15367
16016
  paste: options.paste,
@@ -15382,28 +16031,31 @@ function outputRotationResult(profile, profileName, oldDid, authMethod) {
15382
16031
  }
15383
16032
  async function persistCurrentLocalSession(profileName, profile, session) {
15384
16033
  if (!session) return;
15385
- await ProfileManager.setSession(profileName, {
15386
- authMethod: "local",
15387
- address: session.address,
15388
- chainId: session.chainId,
15389
- spaceId: session.spaceId,
15390
- delegationHeader: session.delegationHeader,
15391
- delegationCid: session.delegationCid,
15392
- jwk: session.jwk,
15393
- verificationMethod: session.verificationMethod,
15394
- siwe: session.siwe,
15395
- signature: session.signature
15396
- });
15397
- if (profile.sessionDid !== session.verificationMethod || profile.spaceId !== session.spaceId) {
15398
- await ProfileManager.setProfile(profileName, {
15399
- ...profile,
15400
- sessionDid: session.verificationMethod,
15401
- spaceId: session.spaceId
16034
+ await ProfileManager.withLock(profileName, async () => {
16035
+ await ProfileManager.setSession(profileName, {
16036
+ authMethod: "local",
16037
+ address: session.address,
16038
+ chainId: session.chainId,
16039
+ spaceId: session.spaceId,
16040
+ delegationHeader: session.delegationHeader,
16041
+ delegationCid: session.delegationCid,
16042
+ jwk: session.jwk,
16043
+ verificationMethod: session.verificationMethod,
16044
+ siwe: session.siwe,
16045
+ signature: session.signature
15402
16046
  });
15403
- }
16047
+ if (profile.sessionDid !== session.verificationMethod || profile.spaceId !== session.spaceId) {
16048
+ await ProfileManager.updateProfile(profileName, (current) => ({
16049
+ ...current,
16050
+ sessionDid: session.verificationMethod,
16051
+ spaceId: session.spaceId
16052
+ }));
16053
+ }
16054
+ });
15404
16055
  }
15405
16056
  async function handleLocalAuth(profileName, host, options = {}) {
15406
- const profile = await ProfileManager.getProfile(profileName).catch(() => null);
16057
+ const snapshot = await readProfileSnapshot(profileName);
16058
+ const profile = snapshot.profile;
15407
16059
  const posture = profile ? resolveProfilePosture(profile) : null;
15408
16060
  let privateKey;
15409
16061
  let address;
@@ -15430,20 +16082,22 @@ async function handleLocalAuth(profileName, host, options = {}) {
15430
16082
  process.stderr.write(formatField("DID", did) + "\n\n");
15431
16083
  }
15432
16084
  }
15433
- const hasKey = await ProfileManager.getKey(profileName);
16085
+ const hasKey = snapshot.key;
16086
+ let key;
15434
16087
  if (options.forceSessionKey || !hasKey) {
15435
16088
  const { jwk, did: generatedSessionDid } = await withSpinner("Generating session key...", async () => {
15436
16089
  return generateKey();
15437
16090
  });
15438
- await ProfileManager.setKey(profileName, jwk);
16091
+ key = jwk;
15439
16092
  sessionDid = generatedSessionDid;
15440
- } else if (!sessionDid) {
15441
- sessionDid = keyToDID(hasKey);
16093
+ } else {
16094
+ key = hasKey;
16095
+ sessionDid ??= keyToDID(hasKey);
15442
16096
  }
15443
16097
  const sessionResult = await withSpinner("Signing in...", async () => {
15444
16098
  return localKeySignIn({ privateKey, host });
15445
16099
  });
15446
- await ProfileManager.setSession(profileName, {
16100
+ const session = {
15447
16101
  authMethod: "local",
15448
16102
  address,
15449
16103
  chainId: DEFAULT_CHAIN_ID,
@@ -15454,7 +16108,7 @@ async function handleLocalAuth(profileName, host, options = {}) {
15454
16108
  verificationMethod: sessionResult.verificationMethod,
15455
16109
  siwe: sessionResult.siwe,
15456
16110
  signature: sessionResult.signature
15457
- });
16111
+ };
15458
16112
  sessionDid = sessionResult.verificationMethod;
15459
16113
  const updatedProfile = {
15460
16114
  ...profile,
@@ -15473,7 +16127,7 @@ async function handleLocalAuth(profileName, host, options = {}) {
15473
16127
  privateKey,
15474
16128
  address
15475
16129
  };
15476
- await ProfileManager.setProfile(profileName, updatedProfile);
16130
+ await commitLogin(profileName, snapshot, { key, session, profile: updatedProfile });
15477
16131
  if (options.emitOutput ?? true) {
15478
16132
  outputJson({
15479
16133
  authenticated: true,
@@ -15488,35 +16142,34 @@ async function handleLocalAuth(profileName, host, options = {}) {
15488
16142
  return { profile: updatedProfile, sessionResult };
15489
16143
  }
15490
16144
  async function handleOpenKeyAuth(profileName, host, options = {}) {
15491
- if (options.device) {
15492
- const result = await ensureShareDeviceAuthorization({
15493
- profileName,
15494
- nodeOrigin: host,
15495
- shareOrigin: "https://share.tinycloud.xyz",
15496
- openkeyHost: process.env.TC_OPENKEY_HOST,
15497
- allowReplaceLocal: true
15498
- });
15499
- outputJson({
15500
- authenticated: true,
15501
- profile: profileName,
15502
- did: result.profile.did,
15503
- spaceId: result.profile.spaceId ?? null,
15504
- authMethod: "openkey",
15505
- mode: "device"
15506
- });
15507
- return;
15508
- }
15509
16145
  const { profile, delegationData } = await refreshOpenKeySession(profileName, host, options);
15510
16146
  outputJson({
15511
16147
  authenticated: true,
15512
16148
  profile: profileName,
15513
16149
  did: profile.did,
15514
16150
  spaceId: delegationData.spaceId,
15515
- authMethod: "openkey"
16151
+ authMethod: "openkey",
16152
+ ...options.permissions ? {
16153
+ scoped: true,
16154
+ ownerDid: profile.ownerDid,
16155
+ host,
16156
+ permissions: delegationData.permissions,
16157
+ expiresAt: delegationData.expiresAt,
16158
+ activation: delegationData.hostActivated === true ? "confirmed-by-openkey" : "unverified"
16159
+ } : {}
15516
16160
  });
15517
16161
  }
16162
+ function reportDeclined(declined) {
16163
+ if (declined.length === 0) return;
16164
+ process.stderr.write(
16165
+ `${theme.warn("The owner did not approve:")}
16166
+ ${declined.map((permission) => ` ${compactPermission(permission)}`).join("\n")}
16167
+ `
16168
+ );
16169
+ }
15518
16170
  async function refreshOpenKeySession(profileName, host, options = {}) {
15519
- const key = await ProfileManager.getKey(profileName);
16171
+ const snapshot = await readProfileSnapshot(profileName);
16172
+ const key = snapshot.key;
15520
16173
  if (!key) {
15521
16174
  throw new CLIError(
15522
16175
  "NO_KEY",
@@ -15524,29 +16177,62 @@ async function refreshOpenKeySession(profileName, host, options = {}) {
15524
16177
  ExitCode.AUTH_REQUIRED
15525
16178
  );
15526
16179
  }
15527
- const profile = await ProfileManager.getProfile(profileName);
16180
+ const profile = snapshot.profile ?? await ProfileManager.getProfile(profileName);
16181
+ if (options.permissions !== void 0) {
16182
+ validateLoginPermissions(options.permissions);
16183
+ assertNotLocalOwner(profileName, profile, "Scoped browser login");
16184
+ }
16185
+ const expiry = options.expiry === void 0 ? void 0 : parseRequestedExpiry(options.expiry);
16186
+ const openKeyExpiry = expiry === void 0 ? void 0 : openKeyExpiryParam(expiry);
16187
+ const expectedOwner = expectedOwnerFor(profileName, profile, options.expectedOwner);
16188
+ if (options.permissions !== void 0 && options.replaceSession !== true) {
16189
+ const estimatedExpiry = expiry === void 0 ? void 0 : new Date(Date.now() + expiry.durationMs).toISOString();
16190
+ assertSessionReplaceable(profileName, snapshot, expectedOwner, options.permissions, estimatedExpiry);
16191
+ }
15528
16192
  const acquireOpenKey = options.openKeyAcquisition ?? startAuthFlow;
15529
16193
  const delegationData = await acquireOpenKey(profile.did, {
15530
16194
  paste: options.paste,
15531
16195
  noPopup: options.noPopup,
15532
16196
  jwk: key,
15533
16197
  host,
15534
- openkeyHost: resolveOpenKeyHost(profile)
16198
+ openkeyHost: resolveOpenKeyHost(profile),
16199
+ permissions: options.permissions,
16200
+ expiry: openKeyExpiry,
16201
+ ...options.permissions ? { reason: "Allow this TinyCloud CLI profile to use the permissions in the requested manifest." } : {}
15535
16202
  });
15536
- const sanitizedSession = mergePrivateJwkIntoSession(delegationData, key);
15537
- await ProfileManager.setSession(profileName, sanitizedSession);
16203
+ const sessionDid = profile.sessionDid ?? profile.did;
16204
+ let sanitizedSession;
16205
+ let verifiedOwner;
16206
+ if (options.permissions) {
16207
+ sanitizedSession = verifyScopedLogin(delegationData, key, sessionDid, options.permissions, { expectedOwner, expiry });
16208
+ verifiedOwner = sanitizedSession.ownerDid;
16209
+ } else {
16210
+ const carriesProof = typeof delegationData.siwe === "string" && typeof delegationData.signature === "string";
16211
+ const merged = mergePrivateJwkIntoSession(delegationData, key);
16212
+ if (carriesProof || expiry !== void 0 || expectedOwner !== void 0) {
16213
+ const signed = verifySignedSession(delegationData, key, sessionDid, { expectedOwner, expiry });
16214
+ verifiedOwner = signed.ownerDid;
16215
+ sanitizedSession = withVerifiedAuthority(merged, signed);
16216
+ } else {
16217
+ sanitizedSession = Object.fromEntries(Object.entries(withoutTrustFields(merged)).filter(([name]) => name !== "siwe" && name !== "signature"));
16218
+ }
16219
+ }
15538
16220
  const updatedProfile = {
15539
16221
  ...profile,
16222
+ host: options.persistHost === true || !profile.host ? host : profile.host,
15540
16223
  sessionDid: profile.sessionDid ?? profile.did,
15541
16224
  posture: profile.posture ?? "owner-openkey",
15542
16225
  operatorType: profile.operatorType ?? "human",
15543
- authMethod: "openkey"
16226
+ authMethod: "openkey",
16227
+ ...typeof sanitizedSession.spaceId === "string" ? { spaceId: sanitizedSession.spaceId } : {},
16228
+ ...verifiedOwner === void 0 ? {} : { ownerDid: verifiedOwner }
15544
16229
  };
15545
- if (sanitizedSession.spaceId) {
15546
- updatedProfile.spaceId = sanitizedSession.spaceId;
15547
- updatedProfile.ownerDid = sanitizedSession.ownerDid;
15548
- }
15549
- await ProfileManager.setProfile(profileName, updatedProfile);
16230
+ await commitLogin(profileName, snapshot, {
16231
+ key,
16232
+ session: sanitizedSession,
16233
+ profile: updatedProfile,
16234
+ ...options.permissions && verifiedOwner ? { approved: { scope: sanitizedSession.permissions, ownerDid: verifiedOwner, replaceSession: options.replaceSession === true } } : {}
16235
+ });
15550
16236
  return { profile: updatedProfile, delegationData: sanitizedSession };
15551
16237
  }
15552
16238
 
@@ -15680,41 +16366,47 @@ complete -c tc -l quiet -s q -d "Suppress non-essential output"
15680
16366
  `;
15681
16367
  }
15682
16368
 
15683
- // src/commands/delegation.ts
16369
+ // src/commands/context.ts
15684
16370
  init_profiles();
16371
+ init_space();
15685
16372
  init_formatter();
15686
16373
  init_errors();
15687
- init_constants();
15688
-
15689
- // src/lib/duration.ts
15690
- function parseDuration(input) {
15691
- const match = input.match(/^(\d+)(m|h|d|w)$/);
15692
- if (match) {
15693
- const value = parseInt(match[1], 10);
15694
- const unit = match[2];
15695
- const multipliers = {
15696
- m: 60 * 1e3,
15697
- h: 60 * 60 * 1e3,
15698
- d: 24 * 60 * 60 * 1e3,
15699
- w: 7 * 24 * 60 * 60 * 1e3
15700
- };
15701
- return value * multipliers[unit];
15702
- }
15703
- const date = new Date(input);
15704
- if (!isNaN(date.getTime())) {
15705
- const ms2 = date.getTime() - Date.now();
15706
- if (ms2 <= 0) {
15707
- throw new Error(`Expiry date "${input}" is in the past`);
16374
+ function registerContextCommand(program) {
16375
+ program.command("context").description("Report selected identity, host, space and local session state as JSON (does not test access)").option("--space <name|uri>", "Resolve this space in the selected profile").action(async (options, cmd) => {
16376
+ try {
16377
+ const ctx = await ProfileManager.resolveContext(cmd.optsWithGlobals());
16378
+ const profile = await ProfileManager.getProfile(ctx.profile);
16379
+ const session = await ProfileManager.getSession(ctx.profile);
16380
+ const spaceId = await resolveSpaceUri(options.space, ctx.profile) ?? (typeof session?.spaceId === "string" ? session.spaceId : profile.spaceId ?? null);
16381
+ const expiresAt = sessionExpiresAt(session);
16382
+ let root = cmd;
16383
+ while (root.parent) root = root.parent;
16384
+ outputJson({
16385
+ schemaVersion: 1,
16386
+ cliVersion: root.version() ?? null,
16387
+ profile: ctx.profile,
16388
+ ownerDid: profile.ownerDid ?? null,
16389
+ sessionDid: profile.sessionDid ?? profile.did,
16390
+ host: ctx.host,
16391
+ spaceId,
16392
+ session: {
16393
+ state: session === null ? "missing" : expiresAt === null ? "unknown-expiry" : Date.parse(expiresAt) <= Date.now() ? "expired" : "present",
16394
+ expiresAt,
16395
+ evidence: "local-metadata"
16396
+ },
16397
+ access: "not-tested"
16398
+ });
16399
+ } catch (error) {
16400
+ handleError(error);
15708
16401
  }
15709
- return ms2;
15710
- }
15711
- throw new Error(`Invalid duration: "${input}". Use format like "1h", "7d", or an ISO date.`);
15712
- }
15713
- function parseExpiry2(input) {
15714
- return new Date(Date.now() + parseDuration(input));
16402
+ });
15715
16403
  }
15716
16404
 
15717
16405
  // src/commands/delegation.ts
16406
+ init_profiles();
16407
+ init_formatter();
16408
+ init_errors();
16409
+ init_constants();
15718
16410
  function normalizeDid(input) {
15719
16411
  const normalized = input.trim();
15720
16412
  const fragmentIndex = normalized.indexOf("#");
@@ -16091,29 +16783,37 @@ ${rowCount} row${rowCount === 1 ? "" : "s"} returned`) + "\n");
16091
16783
  }
16092
16784
 
16093
16785
  // src/commands/enable.ts
16786
+ init_constants();
16094
16787
  init_profiles();
16095
16788
  init_errors();
16096
16789
  init_formatter();
16097
16790
  function registerEnableCommand(program) {
16098
16791
  const enable = program.command("enable").description("Enable a narrowly scoped TinyCloud service");
16099
- enable.command("share").description("Approve Share publishing through OpenKey device authorization").action(async (_options, command) => {
16792
+ enable.command("share").description("Approve Share publishing scope through OpenKey device authorization (same as `tc auth login --device --manifest builtin:share-publishing`). Use a dedicated profile, e.g. `tc init --name publisher --key-only && tc --profile publisher enable share`; keep existing profiles for their apps.").option("--replace-session", "Replace this profile's live session even though the new scope would narrow, change or shorten it (prefer a new profile)").action(async (options, command) => {
16100
16793
  try {
16101
- const context = await ProfileManager.resolveContext(command.optsWithGlobals());
16102
- const result = await ensureShareDeviceAuthorization({
16794
+ const globalOptions = command.optsWithGlobals();
16795
+ const context = await ProfileManager.resolveContext(globalOptions);
16796
+ const { profile, result } = await loginWithDeviceAuthorization({
16103
16797
  profileName: context.profile,
16104
16798
  nodeOrigin: context.host,
16105
- shareOrigin: "https://share.tinycloud.xyz",
16106
- openkeyHost: process.env.TC_OPENKEY_HOST,
16107
- allowReplaceLocal: true
16799
+ shareOrigin: DEFAULT_SHARE_ORIGIN,
16800
+ permissions: sharePublishingPermissions(),
16801
+ reason: "Allow this TinyCloud CLI profile to publish Share links.",
16802
+ replaceSession: options.replaceSession === true,
16803
+ persistHost: globalOptions.host !== void 0
16108
16804
  });
16109
16805
  const value = {
16110
16806
  enabled: true,
16111
16807
  service: "share",
16112
16808
  profile: context.profile,
16113
- sessionDid: result.profile.sessionDid ?? result.profile.did,
16114
- expiresAt: result.delegation.expiresAt ?? result.delegation.expirationTime ?? result.delegation.expiry
16809
+ sessionDid: profile.sessionDid ?? profile.did,
16810
+ ownerDid: result.ownerDid,
16811
+ spaceId: result.spaceId,
16812
+ permissions: result.approved,
16813
+ declined: result.declined,
16814
+ expiresAt: result.expiresAt
16115
16815
  };
16116
- output(value, () => `Share enabled for profile ${context.profile}.`);
16816
+ output(value, () => result.declined.length === 0 ? `Share enabled for profile ${context.profile}.` : `Share enabled for profile ${context.profile} with ${result.declined.length} capability group(s) declined; publishing may fail.`);
16117
16817
  } catch (error) {
16118
16818
  handleError(error);
16119
16819
  }
@@ -16174,24 +16874,16 @@ function registerInitCommand(program) {
16174
16874
  });
16175
16875
  return;
16176
16876
  }
16177
- const delegationData = await startAuthFlow(did, {
16877
+ const { delegationData } = await refreshOpenKeySession(profileName, host, {
16178
16878
  paste: options.paste,
16179
16879
  noPopup: options.popup === false,
16180
- jwk,
16181
- host
16182
- });
16183
- const sanitizedSession = mergePrivateJwkIntoSession(delegationData, jwk);
16184
- await ProfileManager.setSession(profileName, sanitizedSession);
16185
- await ProfileManager.setProfile(profileName, {
16186
- ...profileConfig,
16187
- spaceId: sanitizedSession.spaceId,
16188
- ownerDid: sanitizedSession.ownerDid
16880
+ persistHost: true
16189
16881
  });
16190
16882
  outputJson({
16191
16883
  profile: profileName,
16192
16884
  did,
16193
16885
  host,
16194
- spaceId: sanitizedSession.spaceId,
16886
+ spaceId: delegationData.spaceId,
16195
16887
  authenticated: true
16196
16888
  });
16197
16889
  } catch (error) {
@@ -16661,7 +17353,7 @@ function registerProfileCommand(program) {
16661
17353
  ).action(async (name, options, cmd) => {
16662
17354
  try {
16663
17355
  const globalOpts = cmd.optsWithGlobals();
16664
- const host = options.host ?? globalOpts.host ?? "https://node.tinycloud.xyz";
17356
+ const host = options.host ?? globalOpts.host ?? DEFAULT_HOST;
16665
17357
  const posture = parseProfilePosture(options.posture);
16666
17358
  const operatorType = parseOperatorType(options.operator);
16667
17359
  if (await ProfileManager.profileExists(name)) {
@@ -16769,9 +17461,8 @@ Examples:
16769
17461
  ExitCode.USAGE_ERROR
16770
17462
  );
16771
17463
  }
16772
- const p = await ProfileManager.getProfile(profileName);
16773
17464
  const defaultSpace = options.unset ? void 0 : name;
16774
- await ProfileManager.setProfile(profileName, { ...p, defaultSpace });
17465
+ await ProfileManager.updateProfile(profileName, (p) => ({ ...p, defaultSpace }));
16775
17466
  outputJson({ profile: profileName, defaultSpace: defaultSpace ?? null, updated: true });
16776
17467
  } catch (error) {
16777
17468
  handleError(error);
@@ -20118,15 +20809,15 @@ var makeIssue2 = (params) => {
20118
20809
  message: issueData.message
20119
20810
  };
20120
20811
  }
20121
- let errorMessage = "";
20812
+ let errorMessage2 = "";
20122
20813
  const maps = errorMaps.filter((m) => !!m).slice().reverse();
20123
20814
  for (const map of maps) {
20124
- errorMessage = map(fullIssue, { data, defaultError: errorMessage }).message;
20815
+ errorMessage2 = map(fullIssue, { data, defaultError: errorMessage2 }).message;
20125
20816
  }
20126
20817
  return {
20127
20818
  ...issueData,
20128
20819
  path: fullPath,
20129
- message: errorMessage
20820
+ message: errorMessage2
20130
20821
  };
20131
20822
  };
20132
20823
  var EMPTY_PATH2 = [];
@@ -26552,7 +27243,7 @@ function receiveJson(result, path) {
26552
27243
 
26553
27244
  // src/share/io.ts
26554
27245
  import { constants } from "fs";
26555
- import { lstat, mkdir as mkdir3, mkdtemp, open as open3, readFile as readFile9, realpath, stat as stat2, link, rename as rename2, rm as rm2, unlink } from "fs/promises";
27246
+ import { lstat, mkdir as mkdir3, mkdtemp, open as open3, readFile as readFile9, realpath, stat as stat2, link, rename as rename2, rm as rm3, unlink } from "fs/promises";
26556
27247
  import { randomBytes as randomBytes4 } from "crypto";
26557
27248
  import { basename as basename2, join as join6, resolve as resolve2, sep } from "path";
26558
27249
  var MAX_SHARE_STDIN_BYTES = 100 * 1024 * 1024;
@@ -26666,7 +27357,7 @@ async function writeShareOutput(directory, filename, bytes, force) {
26666
27357
  if (temporaryPath !== void 0) await unlink(temporaryPath);
26667
27358
  } catch {
26668
27359
  }
26669
- await rm2(stagingDirectory, { recursive: true, force: true });
27360
+ await rm3(stagingDirectory, { recursive: true, force: true });
26670
27361
  await directoryHandle.close();
26671
27362
  }
26672
27363
  return join6(outputDirectory, safeName);
@@ -26706,23 +27397,24 @@ function shareCliError(error) {
26706
27397
  const profileName = "profileName" in failure ? failure.profileName : void 0;
26707
27398
  const localKey = "localKey" in failure && failure.localKey === true;
26708
27399
  const profileHint = profileName === void 0 ? "" : `--profile ${profileName} `;
26709
- const loginHint = `tc ${profileHint}auth login${localKey ? " --method local" : ""}`;
27400
+ const loginHint = localKey ? `\`tc ${profileHint}auth login --method local\`` : `\`tc ${profileHint}auth login --device --manifest builtin:share-publishing\` (or \`tc ${profileHint}enable share\`)`;
26710
27401
  if (failure.kind === "owner-space-unresolved") {
26711
- return new CLIError("AUTH_REQUIRED", `a valid signed TinyCloud session is required; run \`${loginHint}\``, 3);
27402
+ return new CLIError("AUTH_REQUIRED", `a valid signed TinyCloud session is required; run ${loginHint}`, 3);
26712
27403
  }
26713
27404
  if (failure.kind === "scope-denied") {
26714
27405
  const requiredAction = failure.requiredAction === void 0 ? "" : ` (${failure.requiredAction})`;
26715
- return new CLIError("PERMISSION_DENIED", `the session lacks ${failure.capability} authority${requiredAction}; renew it with \`${loginHint}\` using the required capability`, 5);
27406
+ const renew = localKey ? `renew it with ${loginHint} using the required capability` : `request the builtin:share-publishing scope with ${loginHint}`;
27407
+ return new CLIError("PERMISSION_DENIED", `the session lacks ${failure.capability} authority${requiredAction}; ${renew}`, 5);
26716
27408
  }
26717
27409
  if (failure.kind === "lifetime-exceeds-session") {
26718
27410
  const expiresAt = failure.sessionExpiresAt.toISOString();
26719
27411
  if (failure.reason === "session-too-close") {
26720
- return new CLIError("AUTH_REQUIRED", `the signed session expires too soon (${expiresAt}); log in again with \`${loginHint}\``, 3);
27412
+ return new CLIError("AUTH_REQUIRED", `the signed session expires too soon (${expiresAt}); log in again with ${loginHint}`, 3);
26721
27413
  }
26722
27414
  if (failure.reason === "below-minimum") {
26723
27415
  return new CLIError("SESSION_LIFETIME_EXCEEDED", "share expiry must be at least 60 seconds from now; use a longer --expires value", 2);
26724
27416
  }
26725
- return new CLIError("SESSION_LIFETIME_EXCEEDED", `requested share lifetime exceeds session expiry ${expiresAt}; use a shorter --expires value or renew the session with \`${loginHint}\``, 2);
27417
+ return new CLIError("SESSION_LIFETIME_EXCEEDED", `requested share lifetime exceeds session expiry ${expiresAt}; use a shorter --expires value or renew the session with ${loginHint}`, 2);
26726
27418
  }
26727
27419
  if (failure.kind === "origin-mismatch") {
26728
27420
  return new CLIError("ORIGIN_MISMATCH", "share origin does not match the configured service", 2);
@@ -27115,8 +27807,7 @@ it directly with \`tc space host <name>\` (no request needed).
27115
27807
  try {
27116
27808
  const globalOpts = cmd.optsWithGlobals();
27117
27809
  const ctx = await ProfileManager.resolveContext(globalOpts);
27118
- const profile = await ProfileManager.getProfile(ctx.profile);
27119
- await ProfileManager.setProfile(ctx.profile, { ...profile, spaceName: name });
27810
+ await ProfileManager.updateProfile(ctx.profile, (profile) => ({ ...profile, spaceName: name }));
27120
27811
  outputJson({ profile: ctx.profile, spaceName: name, switched: true });
27121
27812
  } catch (error) {
27122
27813
  handleError(error);
@@ -27415,7 +28106,7 @@ init_errors();
27415
28106
  init_formatter();
27416
28107
  init_theme();
27417
28108
  import {
27418
- NodeWasmBindings
28109
+ NodeWasmBindings as NodeWasmBindings2
27419
28110
  } from "@tinycloud/node-sdk";
27420
28111
  var wasmBindings = null;
27421
28112
  function registerStatusCommand(program) {
@@ -27658,7 +28349,7 @@ function parseDate2(value) {
27658
28349
  return Number.isNaN(date.getTime()) ? null : date;
27659
28350
  }
27660
28351
  function getWasmBindings() {
27661
- wasmBindings ??= new NodeWasmBindings();
28352
+ wasmBindings ??= new NodeWasmBindings2();
27662
28353
  return wasmBindings;
27663
28354
  }
27664
28355
  function normalizeService2(service) {
@@ -28159,6 +28850,7 @@ function registerVarsCommand(program) {
28159
28850
  // src/command-registry.ts
28160
28851
  function registerTinyCloudCommands(program) {
28161
28852
  registerInitCommand(program);
28853
+ registerContextCommand(program);
28162
28854
  registerAuthCommand(program);
28163
28855
  registerEnableCommand(program);
28164
28856
  registerKvCommand(program);