alexandr 0.2.2 → 0.3.1
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 +74 -1
- package/package.json +4 -3
- package/src/app/build.js +124 -0
- package/src/app/config.js +326 -0
- package/src/app/deploy.js +234 -0
- package/src/app/dev.js +177 -0
- package/src/app/entitle.js +139 -0
- package/src/app/index.js +125 -0
- package/src/app/link.js +187 -0
- package/src/app/multipart.js +53 -0
- package/src/app/publish.js +421 -0
- package/src/app/reach.js +100 -0
- package/src/app/rollback.js +72 -0
- package/src/app/signing.js +175 -0
- package/src/app/store.js +191 -0
- package/src/app/token.js +83 -0
- package/src/app/update.js +163 -0
- package/src/cli.js +8 -1
- package/src/commands.js +97 -12
- package/src/completion.js +15 -0
- package/src/consent.js +272 -0
- package/src/deps.js +1 -2
- package/src/instance.js +34 -1
- package/src/link.js +23 -270
- package/src/prompt.js +56 -0
- package/src/updater.js +276 -0
- package/templates/docker-compose.yml +33 -0
- package/templates/env.example +10 -0
package/src/app/reach.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Reaching the linked workspace's kernel from this laptop — the two lines every
|
|
2
|
+
// `alexandr app` verb that talks to a running box needs, in one place.
|
|
3
|
+
//
|
|
4
|
+
// The CP is the single identity authority (account-required-runtimes.md): a
|
|
5
|
+
// workspace token is minted per command, five-minute TTL, presented as a bearer.
|
|
6
|
+
// A LOCAL-TRUST box (`ALEXANDR_LOCAL_TRUST=1`, the dev source-run kernel) has no
|
|
7
|
+
// CP and nothing to present — a different lane, not a degraded one.
|
|
8
|
+
//
|
|
9
|
+
// ⚠ Lifted from `deploy.js`'s auth block rather than exported from it: that verb
|
|
10
|
+
// owns a build and a packer beside its auth, and importing it to reach a URL
|
|
11
|
+
// would drag `@alexandr/app-build` into a command that writes one HTTP request.
|
|
12
|
+
|
|
13
|
+
import { fail } from "../util.js";
|
|
14
|
+
import { EXIT } from "../exit.js";
|
|
15
|
+
import { isLocalTrust, readAuth, authUsable } from "./store.js";
|
|
16
|
+
import { ensureWorkspaceToken } from "./token.js";
|
|
17
|
+
import { requireLink } from "./dev.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The base URL of the linked workspace and the header to present, or exit with
|
|
21
|
+
* the reason. Never returns a partial answer.
|
|
22
|
+
*/
|
|
23
|
+
export async function reachWorkspace(project) {
|
|
24
|
+
const link = requireLink(project);
|
|
25
|
+
let authorization = null;
|
|
26
|
+
let runtimeUrl = link.instanceUrl;
|
|
27
|
+
|
|
28
|
+
if (!isLocalTrust(link)) {
|
|
29
|
+
const session = readAuth();
|
|
30
|
+
if (!authUsable(session, link.cpUrl)) {
|
|
31
|
+
fail("Your account session has expired. Run `alexandr app link --relink`.", EXIT.GENERAL);
|
|
32
|
+
}
|
|
33
|
+
const minted = await ensureWorkspaceToken(link, session, { project, force: true });
|
|
34
|
+
if (!minted.ok) {
|
|
35
|
+
if (minted.pending) {
|
|
36
|
+
fail(`The workspace is ${minted.status} — start it, then try again.`, EXIT.NO_INSTANCE);
|
|
37
|
+
}
|
|
38
|
+
fail(`Couldn't get a workspace token: ${minted.error}`, EXIT.GENERAL);
|
|
39
|
+
}
|
|
40
|
+
authorization = `Bearer ${minted.token}`;
|
|
41
|
+
runtimeUrl = minted.url || runtimeUrl;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!runtimeUrl) {
|
|
45
|
+
fail("The linked workspace has no URL yet. Start it, then try again.", EXIT.NO_INSTANCE);
|
|
46
|
+
}
|
|
47
|
+
return { url: runtimeUrl.replace(/\/+$/, ""), authorization, link };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The headers for a reached workspace — `authorization` only when there is one. */
|
|
51
|
+
export const headersFor = (authorization, json = false) => ({
|
|
52
|
+
...(json ? { "content-type": "application/json" } : {}),
|
|
53
|
+
...(authorization ? { authorization } : {}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* PUT json → `{ data }` / `{ error, status }`, the shape `postJson`/`getJson`
|
|
58
|
+
* already answer in (consent.js). Written here because the runtime's own JSON
|
|
59
|
+
* helpers stop at GET and POST.
|
|
60
|
+
*
|
|
61
|
+
* ⚠ THE BODY IS WHERE A VALUE GOES. Everything this CLI sends to
|
|
62
|
+
* `/_kernel/secrets/...` rides here, never in the path and never in a query —
|
|
63
|
+
* a path is logged by every proxy in front of the box; a body is not.
|
|
64
|
+
*/
|
|
65
|
+
export async function putJson(url, body, headers = {}) {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(url, {
|
|
68
|
+
method: "PUT",
|
|
69
|
+
headers: { "content-type": "application/json", ...headers },
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
});
|
|
72
|
+
const data = await res.json().catch(() => null);
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const detail = typeof data?.error === "string" && data.error ? ` — ${data.error}` : "";
|
|
75
|
+
return { error: `HTTP ${res.status}${detail}`, status: res.status };
|
|
76
|
+
}
|
|
77
|
+
return data == null ? { error: "malformed response" } : { data };
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return { error: `couldn't reach ${safeOrigin(url)}: ${e?.message ?? e}` };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeOrigin(url) {
|
|
84
|
+
try {
|
|
85
|
+
return new URL(url).origin;
|
|
86
|
+
} catch {
|
|
87
|
+
return url;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The slot-name grammar, transcribed from the secrets engine's `SECRET_NAME_RE`
|
|
93
|
+
* (`os/kernel/packages/secrets/src/contract.ts`) so a typo is refused here, with
|
|
94
|
+
* the rule spelled out, rather than as a 400 from the far end.
|
|
95
|
+
*/
|
|
96
|
+
export const SECRET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
97
|
+
|
|
98
|
+
/** An app id is a slug — the same shape the kernel mints and the path segment
|
|
99
|
+
* this CLI builds, so a bad one cannot escape the path it is encoded into. */
|
|
100
|
+
export const APP_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `alexandr app rollback` — put the linked workspace back on the release before
|
|
2
|
+
// this one.
|
|
3
|
+
//
|
|
4
|
+
// The terminal door to `POST /_kernel/store/rollback` (app-system-stage-3.md
|
|
5
|
+
// WP-N). A rollback is a RENAME: the previous release is still on the volume, so
|
|
6
|
+
// this works on a box whose catalog is unreachable — which is exactly the box a
|
|
7
|
+
// bad update leaves behind. No network beyond the one request.
|
|
8
|
+
//
|
|
9
|
+
// ⚠⚠ THE DATA DOES NOT ROLL BACK. Migrations are forward-only, so the app comes
|
|
10
|
+
// back at N-1 while its database stays at N's layout. Every surface offering
|
|
11
|
+
// this has to say so BEFORE it acts, and this one prints the sentence above the
|
|
12
|
+
// request rather than beside the result — a warning shown after the fact is a
|
|
13
|
+
// post-mortem, not a warning.
|
|
14
|
+
//
|
|
15
|
+
// ⚠ A 409 here is NOT the answer a 409 to `update` is. "Nothing newer to install"
|
|
16
|
+
// leaves the box where the developer wanted it; "nothing to go back to" leaves it
|
|
17
|
+
// on the release they are trying to escape — so this one exits non-zero.
|
|
18
|
+
|
|
19
|
+
import { bold, cyan, dim, fail, log, ok, step, warn } from "../util.js";
|
|
20
|
+
import { EXIT } from "../exit.js";
|
|
21
|
+
import { headersFor, reachWorkspace } from "./reach.js";
|
|
22
|
+
import { postKernelJson, projectFor, resolveAppId } from "./update.js";
|
|
23
|
+
|
|
24
|
+
export const ROLLBACK_HELP = `${bold("alexandr app rollback")} — put the linked workspace back on the previous release
|
|
25
|
+
|
|
26
|
+
${bold("USAGE")}
|
|
27
|
+
alexandr app rollback [--app <id>] [--dir <path>]
|
|
28
|
+
|
|
29
|
+
${bold("OPTIONS")}
|
|
30
|
+
--app <id> The app to roll back, when it isn't the one in this folder
|
|
31
|
+
--dir <path> The app folder, when it isn't the current one
|
|
32
|
+
|
|
33
|
+
${dim("Switches back to the release the box still keeps. Your data keeps the newer layout — only additive changes are safe across a rollback. Needs the os.apps.manage permission in that workspace.")}`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* THE sentence. Verbatim, once, in one place — the app page's Versions section
|
|
37
|
+
* says the same thing, and two surfaces phrasing a data-loss caveat differently
|
|
38
|
+
* is how one of them ends up phrasing it wrongly.
|
|
39
|
+
*/
|
|
40
|
+
export const ROLLBACK_WARNING =
|
|
41
|
+
"Your data keeps the newer layout — only additive changes are safe across a rollback.";
|
|
42
|
+
|
|
43
|
+
export async function appRollback(flags, project) {
|
|
44
|
+
// ⚠ `--help` before anything that can exit — see the note in publish.js.
|
|
45
|
+
if (flags.help || flags.h) return void log(ROLLBACK_HELP);
|
|
46
|
+
const dir = project ?? projectFor(flags);
|
|
47
|
+
const chosen = resolveAppId(flags, dir);
|
|
48
|
+
if (chosen.error) fail(chosen.error, EXIT.USAGE);
|
|
49
|
+
const id = chosen.id;
|
|
50
|
+
|
|
51
|
+
const { url, authorization } = await reachWorkspace(dir);
|
|
52
|
+
// Said BEFORE the request, so it is a warning rather than a post-mortem.
|
|
53
|
+
warn(ROLLBACK_WARNING);
|
|
54
|
+
step(`Rolling ${bold(id)} back in ${cyan(url)}…`);
|
|
55
|
+
const res = await postKernelJson(`${url}/_kernel/store/rollback`, { id }, headersFor(authorization, true));
|
|
56
|
+
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
if (res.status === 403) {
|
|
59
|
+
fail(
|
|
60
|
+
`The workspace refused it (403) — changing which version an app runs needs the 'os.apps.manage' permission in that workspace. ${res.error ?? ""}`.trim(),
|
|
61
|
+
EXIT.GENERAL,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (res.status === 409) {
|
|
65
|
+
fail(res.error || `no earlier release of ${id} is retained`, EXIT.GENERAL);
|
|
66
|
+
}
|
|
67
|
+
fail(`Couldn't roll ${id} back: ${res.error}`, EXIT.GENERAL);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const { version, previous } = res.data ?? {};
|
|
71
|
+
ok(`${bold(id)} is back on ${bold(version ?? "its previous release")}${previous ? dim(` (was ${previous})`) : ""}`);
|
|
72
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// THE PUBLISHER IDENTITY THIS LAPTOP SIGNS WITH — the key store and the bytes.
|
|
2
|
+
//
|
|
3
|
+
// A release is signed by its publisher, and a publisher is an ACCOUNT or a
|
|
4
|
+
// WORKSPACE (app-system-stage-3.md WP-O §1.5). The workspace case is the box's
|
|
5
|
+
// own key (`os/kernel/src/apps/publisher-key.ts`); THIS file is the account
|
|
6
|
+
// case: `alexandr app publish` mints one ed25519 pair per machine, keeps the
|
|
7
|
+
// private half in `~/.alexandr/publisher.json` at mode 0600, and registers only
|
|
8
|
+
// the public half with the control plane.
|
|
9
|
+
//
|
|
10
|
+
// ⚠⚠ THE PRIVATE HALF NEVER LEAVES THIS FILE'S DIRECTORY. It is never an
|
|
11
|
+
// argument, never a log line, never a multipart part, never a field the CP is
|
|
12
|
+
// offered. The whole point of the scheme is that a control-plane breach can
|
|
13
|
+
// refuse a release but cannot forge one — a code path that uploads the private
|
|
14
|
+
// key would quietly delete that property. Nothing here has one; nothing should
|
|
15
|
+
// ever add one.
|
|
16
|
+
|
|
17
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign } from "node:crypto";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
import { accountDir, readJson, writeSecretJson } from "./store.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* THE canonical bytes a release signature covers — `${id}\n${version}\n${sha256}`, UTF-8.
|
|
23
|
+
*
|
|
24
|
+
* ⚠ THIS IS AN INLINE COPY of the one definition in
|
|
25
|
+
* `os/protocols/catalog/src/index.ts` (`canonicalReleaseBytes`), and it is a
|
|
26
|
+
* copy on purpose: this CLI is ZERO-DEPENDENCY plain Node, so it cannot import
|
|
27
|
+
* `@alexandr/catalog-protocol` — the package the control plane verifies with and
|
|
28
|
+
* the runtime re-checks with. Four parties must agree byte for byte, and if this
|
|
29
|
+
* copy drifts every signature this machine issues stops verifying, silently.
|
|
30
|
+
*
|
|
31
|
+
* ⚠ So the bytes are PINNED on both sides rather than round-tripped:
|
|
32
|
+
* `os/protocols/catalog/test/release-signing.test.ts` asserts the exact array
|
|
33
|
+
* for `("notes", "1.2.3", "abc123")`, and `test/app-publish.test.js` asserts the
|
|
34
|
+
* same array against this function. A change on either side fails a test naming
|
|
35
|
+
* the other.
|
|
36
|
+
*
|
|
37
|
+
* Why these three fields and nothing else: the sha256 already commits to every
|
|
38
|
+
* byte of the package, so binding identity + version + digest is what makes a
|
|
39
|
+
* signature non-transferable to another app, another version, another tarball.
|
|
40
|
+
*/
|
|
41
|
+
export function canonicalReleaseBytes(id, version, sha256) {
|
|
42
|
+
return Buffer.from(`${id}\n${version}\n${sha256}`, "utf8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `~/.alexandr/publisher.json` — this machine's signing identity, mode 0600. */
|
|
46
|
+
export function publisherKeyPath(home) {
|
|
47
|
+
return resolve(accountDir(home), "publisher.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `ak-<16 hex>` over the DER public key.
|
|
52
|
+
*
|
|
53
|
+
* DERIVED, never chosen: the id is a hash of the key material, so it is stable
|
|
54
|
+
* across restarts and identical for identical bytes — which is what makes
|
|
55
|
+
* re-registering after a dropped response a no-op instead of a second identity
|
|
56
|
+
* (and what makes the CP's 409 a success, not a failure — see publish.js).
|
|
57
|
+
*
|
|
58
|
+
* ⚠ The kernel's workspace key uses `wk-` with the same derivation: a different
|
|
59
|
+
* ACTOR, not a different scheme. The prefix is what tells a reader looking at a
|
|
60
|
+
* feed entry which door signed it. It must satisfy the control plane's `isKeyId`
|
|
61
|
+
* (`/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/`, `cp-core/src/app-releases.ts`), which
|
|
62
|
+
* hex plus a leading `ak-` does by construction.
|
|
63
|
+
*/
|
|
64
|
+
export function keyIdFor(publicKeyPem) {
|
|
65
|
+
const der = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
|
|
66
|
+
return `ak-${createHash("sha256").update(der).digest("hex").slice(0, 16)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* This machine's publisher key, minting one the first time.
|
|
71
|
+
*
|
|
72
|
+
* Answers `{ record, minted }` — `minted` is what tells `publish` it must
|
|
73
|
+
* register the public half before the release POST can succeed. A record that
|
|
74
|
+
* is present but missing a half is treated as absent and replaced: half a key
|
|
75
|
+
* pair signs nothing, and refusing to publish over one would strand a developer
|
|
76
|
+
* on a file they cannot read.
|
|
77
|
+
*/
|
|
78
|
+
export function loadOrMintPublisherKey(home) {
|
|
79
|
+
const file = publisherKeyPath(home);
|
|
80
|
+
const stored = readJson(file);
|
|
81
|
+
if (stored?.keyId && stored?.publicKey && stored?.privateKey) {
|
|
82
|
+
return { record: stored, minted: false };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
86
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
87
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
88
|
+
const record = {
|
|
89
|
+
keyId: keyIdFor(publicKeyPem),
|
|
90
|
+
publicKey: publicKeyPem,
|
|
91
|
+
privateKey: privateKeyPem,
|
|
92
|
+
createdAt: new Date().toISOString(),
|
|
93
|
+
// Which control planes already hold the public half — see markRegistered.
|
|
94
|
+
registrations: {},
|
|
95
|
+
};
|
|
96
|
+
// ⚠ 0600 (best-effort on Windows, like every other credential this CLI keeps —
|
|
97
|
+
// see `writeSecretJson`). This file is the one secret `alexandr app` creates
|
|
98
|
+
// rather than receives.
|
|
99
|
+
writeSecretJson(file, record);
|
|
100
|
+
return { record, minted: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A publisher key handed in by the ENVIRONMENT — CI's key (app-system-stage-4.md
|
|
105
|
+
* WP-S). `ALEXANDR_PUBLISHER_KEY` holds a PKCS8 ed25519 private PEM (make one with
|
|
106
|
+
* `openssl genpkey -algorithm ed25519`); the public half and the id are derived
|
|
107
|
+
* from it, nothing is written to disk, and `registrations` is empty because a
|
|
108
|
+
* runner has no memory between jobs — the CP's 409 makes re-registering free.
|
|
109
|
+
*
|
|
110
|
+
* ⚠ Refuses anything that is not an ed25519 private key with a message that names
|
|
111
|
+
* the variable: a runner that pasted the wrong secret should read "the key", not
|
|
112
|
+
* a stack trace from `createPrivateKey`.
|
|
113
|
+
*/
|
|
114
|
+
export function publisherKeyFromEnv(env = process.env) {
|
|
115
|
+
const pem = env.ALEXANDR_PUBLISHER_KEY;
|
|
116
|
+
if (typeof pem !== "string" || !pem.trim()) return null;
|
|
117
|
+
let privateKey;
|
|
118
|
+
try {
|
|
119
|
+
privateKey = createPrivateKey(pem.trim().replace(/\\n/g, "\n"));
|
|
120
|
+
} catch (e) {
|
|
121
|
+
throw new Error(`ALEXANDR_PUBLISHER_KEY is not a private key PEM (${e.message})`);
|
|
122
|
+
}
|
|
123
|
+
if (privateKey.asymmetricKeyType !== "ed25519") {
|
|
124
|
+
throw new Error(`ALEXANDR_PUBLISHER_KEY must be an ed25519 key (got ${privateKey.asymmetricKeyType})`);
|
|
125
|
+
}
|
|
126
|
+
const publicKeyPem = createPublicKey(privateKey).export({ type: "spki", format: "pem" }).toString();
|
|
127
|
+
return {
|
|
128
|
+
keyId: keyIdFor(publicKeyPem),
|
|
129
|
+
publicKey: publicKeyPem,
|
|
130
|
+
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
131
|
+
registrations: {},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A key for THIS RUN only — the CI fallback when no `ALEXANDR_PUBLISHER_KEY` is
|
|
137
|
+
* set. Never written anywhere. Each run registers a fresh public half under the
|
|
138
|
+
* workspace, which the CP keeps (a release's signature must stay verifiable), so
|
|
139
|
+
* the caller warns and names the variable that makes runs share one key.
|
|
140
|
+
*/
|
|
141
|
+
export function mintEphemeralPublisherKey() {
|
|
142
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
143
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
144
|
+
return {
|
|
145
|
+
keyId: keyIdFor(publicKeyPem),
|
|
146
|
+
publicKey: publicKeyPem,
|
|
147
|
+
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
148
|
+
registrations: {},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** base64url ed25519 over the canonical bytes — the release's signature. */
|
|
153
|
+
export function signRelease(privateKeyPem, id, version, sha256) {
|
|
154
|
+
return sign(null, canonicalReleaseBytes(id, version, sha256), createPrivateKey(privateKeyPem)).toString(
|
|
155
|
+
"base64url",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Remember that `cpUrl` holds this key's public half.
|
|
161
|
+
*
|
|
162
|
+
* Per CP URL, not a boolean: a laptop that publishes to a dev control plane and
|
|
163
|
+
* to production is registered with two independent key stores, and one flag
|
|
164
|
+
* would make the second publish skip the registration it still needs. Recorded
|
|
165
|
+
* only AFTER the CP said so (201 or 409) — an optimistic write would turn one
|
|
166
|
+
* failed registration into a permanent, silent 403 on every later publish.
|
|
167
|
+
*/
|
|
168
|
+
export function markRegistered(home, record, cpUrl) {
|
|
169
|
+
const next = {
|
|
170
|
+
...record,
|
|
171
|
+
registrations: { ...(record.registrations ?? {}), [cpUrl]: new Date().toISOString() },
|
|
172
|
+
};
|
|
173
|
+
writeSecretJson(publisherKeyPath(home), next);
|
|
174
|
+
return next;
|
|
175
|
+
}
|
package/src/app/store.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Where `alexandr app` keeps its state — two files, deliberately in two places.
|
|
2
|
+
//
|
|
3
|
+
// ~/.alexandr/auth.json THE ACCOUNT session (one per machine, mode 0600)
|
|
4
|
+
// <project>/.alexandr/link.json which workspace THIS project deploys to
|
|
5
|
+
// <project>/.alexandr/token.json the short-lived workspace JWT, re-minted
|
|
6
|
+
//
|
|
7
|
+
// The split is the point. An account session is a credential — it belongs to the
|
|
8
|
+
// person and their machine, never in a repo. A workspace link is a project fact
|
|
9
|
+
// — a teammate cloning the repo wants their OWN link, so `.alexandr/` is
|
|
10
|
+
// gitignored by the template and each developer links once. The workspace token
|
|
11
|
+
// lives beside the link because it is derived from it and expires in minutes.
|
|
12
|
+
//
|
|
13
|
+
// Every function here is pure over an injected home/project dir, so the whole
|
|
14
|
+
// credential story is testable without touching a real HOME.
|
|
15
|
+
|
|
16
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The per-machine alexandr directory — `~/.alexandr`, the same one the
|
|
22
|
+
* self-host verbs already keep their global instances in (`instance.js`).
|
|
23
|
+
* `ALEXANDR_HOME` names it outright, which is how the tests get a real store
|
|
24
|
+
* without touching a real HOME.
|
|
25
|
+
*/
|
|
26
|
+
export function accountDir(home) {
|
|
27
|
+
if (home) return resolve(home);
|
|
28
|
+
if (process.env.ALEXANDR_HOME) return resolve(process.env.ALEXANDR_HOME);
|
|
29
|
+
return resolve(homedir(), ".alexandr");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `~/.alexandr/auth.json` — the account session this machine holds. */
|
|
33
|
+
export function authPath(home) {
|
|
34
|
+
return resolve(accountDir(home), "auth.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `<project>/.alexandr` — the per-project link state, gitignored by the template. */
|
|
38
|
+
export function projectDir(project = process.cwd()) {
|
|
39
|
+
return resolve(project, ".alexandr");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function linkPath(project) {
|
|
43
|
+
return resolve(projectDir(project), "link.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function tokenPath(project) {
|
|
47
|
+
return resolve(projectDir(project), "token.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read a JSON file, or null. A corrupt file is "absent", never a crash. */
|
|
51
|
+
export function readJson(file) {
|
|
52
|
+
try {
|
|
53
|
+
const raw = readFileSync(file, "utf8");
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
// ⚠ `JSON.parse("null")` SUCCEEDS — a null body is not a parsed record.
|
|
56
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Write JSON, owner-only.
|
|
64
|
+
*
|
|
65
|
+
* `chmod 0600` is best-effort on purpose: on Windows the POSIX mode is a
|
|
66
|
+
* no-op and throwing there would make the CLI unusable on the platform its own
|
|
67
|
+
* scripts are written for (root CLAUDE.md, "On a WINDOWS dev box"). The file
|
|
68
|
+
* still lands under the user profile, which is the platform's own boundary.
|
|
69
|
+
*/
|
|
70
|
+
export function writeSecretJson(file, value) {
|
|
71
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
72
|
+
writeFileSync(file, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
|
|
73
|
+
try {
|
|
74
|
+
chmodSync(file, 0o600);
|
|
75
|
+
} catch {
|
|
76
|
+
/* Windows: no POSIX mode. */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── the account session ───────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** `{ cpUrl, token, expiresAt, client, scope }`, or null when not signed in. */
|
|
83
|
+
export function readAuth(home) {
|
|
84
|
+
return readJson(authPath(home));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeAuth(session, home) {
|
|
88
|
+
writeSecretJson(authPath(home), session);
|
|
89
|
+
return session;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function clearAuth(home) {
|
|
93
|
+
rmSync(authPath(home), { force: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Is this stored session still usable for `cpUrl`?
|
|
98
|
+
*
|
|
99
|
+
* ⚠ The CP url is part of the answer, not a detail. A session minted against a
|
|
100
|
+
* dev control plane is worthless against production and vice versa; treating
|
|
101
|
+
* "have a token" as "signed in" is how a laptop ends up sending a dev session to
|
|
102
|
+
* api.alexandr.so and reading the 401 as a server problem.
|
|
103
|
+
*/
|
|
104
|
+
export function authUsable(session, cpUrl, now = Date.now()) {
|
|
105
|
+
if (!session?.token) return false;
|
|
106
|
+
if (cpUrl && session.cpUrl && session.cpUrl !== cpUrl) return false;
|
|
107
|
+
if (!session.expiresAt) return true; // an older CP answered without one
|
|
108
|
+
const expires = Date.parse(session.expiresAt);
|
|
109
|
+
return Number.isFinite(expires) ? expires > now : true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── the project link ──────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/** `{ cpUrl, workspaceId, workspaceName, instanceId, instanceUrl, localTrust }`. */
|
|
115
|
+
export function readLink(project) {
|
|
116
|
+
return readJson(linkPath(project));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function writeLink(link, project) {
|
|
120
|
+
const file = linkPath(project);
|
|
121
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
122
|
+
writeFileSync(file, JSON.stringify(link, null, 2) + "\n");
|
|
123
|
+
return link;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A box trusted open behind a signed-in app (`ALEXANDR_LOCAL_TRUST=1`) — no CP,
|
|
127
|
+
* no workspace token, nothing to mint. `alexandr app link --url` writes this. */
|
|
128
|
+
export function isLocalTrust(link) {
|
|
129
|
+
return Boolean(link?.localTrust || (link?.instanceUrl && !link?.workspaceId));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── the workspace token ───────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/** `{ token, expiresAt }` — the cached workspace JWT for this project. */
|
|
135
|
+
export function readToken(project) {
|
|
136
|
+
return readJson(tokenPath(project));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function writeToken(record, project) {
|
|
140
|
+
writeSecretJson(tokenPath(project), record);
|
|
141
|
+
return record;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function clearToken(project) {
|
|
145
|
+
rmSync(tokenPath(project), { force: true });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The margin before expiry at which a cached workspace token is re-minted. */
|
|
149
|
+
export const REMINT_MARGIN_MS = 60_000;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Does this cached token need replacing?
|
|
153
|
+
*
|
|
154
|
+
* ⚠ The margin is what makes `app dev` survive a long request. The CP mints a
|
|
155
|
+
* 5-minute JWT; a proxy that only re-minted ON a 401 would hand Vite a token
|
|
156
|
+
* that expires mid-flight and turn one stale second into a failed page load.
|
|
157
|
+
* Absent, tokenless and unparseable all answer "yes" — the safe direction.
|
|
158
|
+
*/
|
|
159
|
+
export function needsRemint(record, now = Date.now(), marginMs = REMINT_MARGIN_MS) {
|
|
160
|
+
if (!record?.token) return true;
|
|
161
|
+
const expires = Date.parse(record.expiresAt ?? "");
|
|
162
|
+
if (!Number.isFinite(expires)) return true;
|
|
163
|
+
return expires - marginMs <= now;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* When a workspace JWT actually expires, read from the token itself.
|
|
168
|
+
*
|
|
169
|
+
* ⚠ `POST /workspaces/:id/token` answers with the token and NO expiry, so the
|
|
170
|
+
* only honest source is the JWT's own `exp`. A hard-coded "5 minutes from now"
|
|
171
|
+
* would be a guess that silently rots the day the CP changes the TTL. A token we
|
|
172
|
+
* cannot parse falls back to a deliberately SHORT window, so the failure mode is
|
|
173
|
+
* re-minting too often rather than using a dead token.
|
|
174
|
+
*/
|
|
175
|
+
export function jwtExpiry(token, now = Date.now(), fallbackMs = 60_000) {
|
|
176
|
+
try {
|
|
177
|
+
const payload = String(token).split(".")[1];
|
|
178
|
+
if (!payload) return new Date(now + fallbackMs).toISOString();
|
|
179
|
+
const json = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
180
|
+
const exp = Number(json?.exp);
|
|
181
|
+
if (Number.isFinite(exp) && exp > 0) return new Date(exp * 1000).toISOString();
|
|
182
|
+
} catch {
|
|
183
|
+
/* not a JWT we can read — fall through */
|
|
184
|
+
}
|
|
185
|
+
return new Date(now + fallbackMs).toISOString();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** True when the project has been linked at all. */
|
|
189
|
+
export function isLinked(project) {
|
|
190
|
+
return existsSync(linkPath(project));
|
|
191
|
+
}
|
package/src/app/token.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The workspace token — minting it, caching it, and keeping it fresh.
|
|
2
|
+
//
|
|
3
|
+
// The CP is the single identity authority: `POST /workspaces/:id/token` with the
|
|
4
|
+
// account session returns an RS256 JWT whose `aud` is the instance id, with a
|
|
5
|
+
// FIVE-MINUTE TTL, which the runtime verifies locally against the CP JWKS
|
|
6
|
+
// (account-required-runtimes.md). The runtime owns no login, so there is nothing
|
|
7
|
+
// else to present at its door.
|
|
8
|
+
//
|
|
9
|
+
// Five minutes is short enough that `alexandr app dev` cannot mint once and walk
|
|
10
|
+
// away: it re-mints on a timer for as long as Vite runs, and the Vite proxy reads
|
|
11
|
+
// the token file per request so a fresh one lands without a restart.
|
|
12
|
+
//
|
|
13
|
+
// ⚠ `POST /workspaces/:id/token` is mounted under CP_MANAGED=1 only. A
|
|
14
|
+
// local-trust box (`ALEXANDR_LOCAL_TRUST=1`) has no CP and needs no token; that
|
|
15
|
+
// is a different lane, not a degraded one — see `isLocalTrust` in ./store.js.
|
|
16
|
+
|
|
17
|
+
import { postJson } from "../consent.js";
|
|
18
|
+
import { jwtExpiry, needsRemint, readToken, writeToken } from "./store.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Mint a fresh workspace token for `link` using the account `session`.
|
|
22
|
+
*
|
|
23
|
+
* Returns `{ ok, token, expiresAt, status, url }` or `{ ok: false, error }`. A
|
|
24
|
+
* workspace whose instance is not running yet answers with a null token and a
|
|
25
|
+
* status — a WAIT, not a failure, and the caller says so.
|
|
26
|
+
*/
|
|
27
|
+
export async function mintWorkspaceToken(link, session, fetchJson = postJson) {
|
|
28
|
+
const res = await fetchJson(
|
|
29
|
+
`${link.cpUrl.replace(/\/+$/, "")}/workspaces/${encodeURIComponent(link.workspaceId)}/token`,
|
|
30
|
+
{},
|
|
31
|
+
{ authorization: `Bearer ${session.token}` },
|
|
32
|
+
);
|
|
33
|
+
if (res.error) return { ok: false, error: res.error };
|
|
34
|
+
const { token, status, url } = res.data ?? {};
|
|
35
|
+
if (!token) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
pending: true,
|
|
39
|
+
status: status ?? "unknown",
|
|
40
|
+
url: url ?? null,
|
|
41
|
+
error: `the workspace is ${status ?? "not running"} — no token yet`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { ok: true, token, expiresAt: jwtExpiry(token), status, url: url ?? link.instanceUrl };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The token this project should be presenting right now: the cached one while it
|
|
49
|
+
* has more than a minute left, a freshly minted one otherwise.
|
|
50
|
+
*
|
|
51
|
+
* `force` skips the cache (the timer in `app dev` does not need to re-read a
|
|
52
|
+
* file it just wrote).
|
|
53
|
+
*/
|
|
54
|
+
export async function ensureWorkspaceToken(
|
|
55
|
+
link,
|
|
56
|
+
session,
|
|
57
|
+
{ project, force = false, now = Date.now(), fetchJson = postJson } = {},
|
|
58
|
+
) {
|
|
59
|
+
const cached = force ? null : readToken(project);
|
|
60
|
+
if (!needsRemint(cached, now)) return { ok: true, token: cached.token, expiresAt: cached.expiresAt, cached: true };
|
|
61
|
+
|
|
62
|
+
const minted = await mintWorkspaceToken(link, session, fetchJson);
|
|
63
|
+
if (!minted.ok) return minted;
|
|
64
|
+
writeToken({ token: minted.token, expiresAt: minted.expiresAt, workspaceId: link.workspaceId }, project);
|
|
65
|
+
return { ok: true, token: minted.token, expiresAt: minted.expiresAt, cached: false, url: minted.url };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How long to wait before the next re-mint: two thirds of the remaining life,
|
|
70
|
+
* floored at 30 s and capped at 4 minutes.
|
|
71
|
+
*
|
|
72
|
+
* ⚠ A fixed interval is the wrong shape here. Too long and the proxy serves a
|
|
73
|
+
* dead token; too short and a dev session hammers the CP for eight hours. Two
|
|
74
|
+
* thirds of the life re-mints ~3.3 minutes into a 5-minute token — comfortably
|
|
75
|
+
* inside `needsRemint`'s one-minute margin, whatever TTL the CP decides on later.
|
|
76
|
+
*/
|
|
77
|
+
export function nextRemintDelay(expiresAt, now = Date.now(), { minMs = 30_000, maxMs = 240_000 } = {}) {
|
|
78
|
+
const expires = Date.parse(expiresAt ?? "");
|
|
79
|
+
if (!Number.isFinite(expires)) return minMs;
|
|
80
|
+
const remaining = expires - now;
|
|
81
|
+
if (remaining <= 0) return minMs;
|
|
82
|
+
return Math.min(maxMs, Math.max(minMs, Math.floor((remaining * 2) / 3)));
|
|
83
|
+
}
|