@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.
- package/README.md +24 -7
- package/dist/index.js +106 -45
- package/dist/index.js.map +1 -1
- package/dist/legacy-entry.js +1053 -361
- package/dist/legacy-entry.js.map +1 -1
- package/package.json +7 -5
- package/skills/tc-cli/AUTH.md +88 -0
- package/skills/tc-cli/INSTALL.md +28 -0
- package/skills/tc-cli/REFERENCE.md +215 -0
- package/skills/tc-cli/SDK.md +58 -0
- package/skills/tc-cli/SKILL.md +69 -0
- package/skills/tc-cli/release.json +15 -0
package/dist/legacy-entry.js
CHANGED
|
@@ -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
|
-
|
|
524
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
648
|
-
|
|
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
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
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(
|
|
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
|
|
2790
|
+
let errorMessage2 = "";
|
|
2734
2791
|
const maps = errorMaps.filter((m) => !!m).slice().reverse();
|
|
2735
2792
|
for (const map of maps) {
|
|
2736
|
-
|
|
2793
|
+
errorMessage2 = map(fullIssue, { data, defaultError: errorMessage2 }).message;
|
|
2737
2794
|
}
|
|
2738
2795
|
return {
|
|
2739
2796
|
...issueData,
|
|
2740
2797
|
path: fullPath,
|
|
2741
|
-
message:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
13364
|
-
|
|
13365
|
-
|
|
13366
|
-
|
|
13367
|
-
|
|
13368
|
-
|
|
13369
|
-
|
|
13370
|
-
|
|
13371
|
-
|
|
13372
|
-
|
|
13373
|
-
|
|
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
|
-
|
|
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
|
|
14089
|
-
var
|
|
14090
|
-
|
|
14091
|
-
|
|
14092
|
-
|
|
14093
|
-
|
|
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
|
|
14111
|
-
|
|
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
|
|
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 (
|
|
14116
|
-
throw
|
|
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
|
-
|
|
14584
|
+
const value = await response.json();
|
|
14585
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
14123
14586
|
} catch {
|
|
14124
|
-
|
|
14587
|
+
return void 0;
|
|
14125
14588
|
}
|
|
14126
14589
|
}
|
|
14127
14590
|
function errorCode(value) {
|
|
14128
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
14155
|
-
|
|
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
|
|
14158
|
-
const
|
|
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
|
|
14162
|
-
|
|
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
|
|
14659
|
+
throw invalidResponse("OpenKey returned an unreadable encrypted relay result");
|
|
14172
14660
|
}
|
|
14173
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw
|
|
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
|
|
14177
|
-
|
|
14178
|
-
|
|
14179
|
-
|
|
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
|
|
14182
|
-
|
|
14183
|
-
|
|
14184
|
-
|
|
14185
|
-
|
|
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 (
|
|
14188
|
-
throw new
|
|
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
|
-
|
|
14191
|
-
|
|
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 (!
|
|
14197
|
-
throw new
|
|
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(
|
|
14200
|
-
if (invalid) throw
|
|
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
|
-
|
|
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
|
|
14210
|
-
|
|
14741
|
+
const relayKey = createECDH("prime256v1");
|
|
14742
|
+
relayKey.generateKeys();
|
|
14743
|
+
const relayPublicJwk = relayPublicJwkOf(relayKey);
|
|
14211
14744
|
const publicJwk = publicSessionJwk(input.jwk);
|
|
14212
|
-
|
|
14213
|
-
|
|
14214
|
-
|
|
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({
|
|
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
|
|
14257
|
-
|
|
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 (
|
|
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
|
|
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
|
|
14267
|
-
throw
|
|
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,
|
|
14270
|
-
|
|
14271
|
-
|
|
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
|
|
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
|
|
14285
|
-
|
|
14286
|
-
|
|
14287
|
-
|
|
14288
|
-
|
|
14289
|
-
|
|
14290
|
-
|
|
14291
|
-
|
|
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
|
-
|
|
14297
|
-
...
|
|
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:
|
|
14301
|
-
spaceName:
|
|
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
|
-
|
|
14887
|
+
ownerDid: result.ownerDid,
|
|
14888
|
+
spaceId: result.spaceId,
|
|
14889
|
+
createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
14305
14890
|
posture: "owner-openkey",
|
|
14306
|
-
operatorType:
|
|
14307
|
-
authMethod: "openkey"
|
|
14308
|
-
openkeyHost: input.openkeyHost ?? profile?.openkeyHost
|
|
14891
|
+
operatorType: existing?.operatorType ?? "human",
|
|
14892
|
+
authMethod: "openkey"
|
|
14309
14893
|
};
|
|
14310
|
-
await
|
|
14311
|
-
|
|
14312
|
-
|
|
14313
|
-
|
|
14314
|
-
|
|
14315
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
14373
|
-
|
|
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
|
-
|
|
14378
|
-
|
|
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
|
-
|
|
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
|
|
14524
|
-
|
|
14525
|
-
|
|
14526
|
-
|
|
14527
|
-
|
|
14528
|
-
|
|
14529
|
-
|
|
14530
|
-
|
|
14531
|
-
|
|
14532
|
-
|
|
14533
|
-
|
|
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.
|
|
14968
|
-
|
|
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 =
|
|
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 (
|
|
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) =>
|
|
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}|${
|
|
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 :
|
|
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.
|
|
15356
|
-
|
|
15357
|
-
|
|
15358
|
-
|
|
15359
|
-
|
|
15360
|
-
|
|
15361
|
-
|
|
15362
|
-
|
|
15363
|
-
|
|
15364
|
-
|
|
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.
|
|
15386
|
-
|
|
15387
|
-
|
|
15388
|
-
|
|
15389
|
-
|
|
15390
|
-
|
|
15391
|
-
|
|
15392
|
-
|
|
15393
|
-
|
|
15394
|
-
|
|
15395
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
16091
|
+
key = jwk;
|
|
15439
16092
|
sessionDid = generatedSessionDid;
|
|
15440
|
-
} else
|
|
15441
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
15537
|
-
|
|
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
|
-
|
|
15546
|
-
|
|
15547
|
-
|
|
15548
|
-
|
|
15549
|
-
|
|
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/
|
|
16369
|
+
// src/commands/context.ts
|
|
15684
16370
|
init_profiles();
|
|
16371
|
+
init_space();
|
|
15685
16372
|
init_formatter();
|
|
15686
16373
|
init_errors();
|
|
15687
|
-
|
|
15688
|
-
|
|
15689
|
-
|
|
15690
|
-
|
|
15691
|
-
|
|
15692
|
-
|
|
15693
|
-
|
|
15694
|
-
|
|
15695
|
-
|
|
15696
|
-
|
|
15697
|
-
|
|
15698
|
-
|
|
15699
|
-
|
|
15700
|
-
|
|
15701
|
-
|
|
15702
|
-
|
|
15703
|
-
|
|
15704
|
-
|
|
15705
|
-
|
|
15706
|
-
|
|
15707
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
16102
|
-
const
|
|
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:
|
|
16106
|
-
|
|
16107
|
-
|
|
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:
|
|
16114
|
-
|
|
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
|
|
16877
|
+
const { delegationData } = await refreshOpenKeySession(profileName, host, {
|
|
16178
16878
|
paste: options.paste,
|
|
16179
16879
|
noPopup: options.popup === false,
|
|
16180
|
-
|
|
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:
|
|
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 ??
|
|
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.
|
|
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
|
|
20812
|
+
let errorMessage2 = "";
|
|
20122
20813
|
const maps = errorMaps.filter((m) => !!m).slice().reverse();
|
|
20123
20814
|
for (const map of maps) {
|
|
20124
|
-
|
|
20815
|
+
errorMessage2 = map(fullIssue, { data, defaultError: errorMessage2 }).message;
|
|
20125
20816
|
}
|
|
20126
20817
|
return {
|
|
20127
20818
|
...issueData,
|
|
20128
20819
|
path: fullPath,
|
|
20129
|
-
message:
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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);
|