@tokenfactory/acc-runner 0.41.6 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -4
- package/dist/acc.d.ts +3 -0
- package/dist/acc.d.ts.map +1 -0
- package/dist/acc.js +21 -0
- package/dist/acc.js.map +1 -0
- package/dist/cli-name.d.ts +29 -0
- package/dist/cli-name.d.ts.map +1 -0
- package/dist/cli-name.js +17 -0
- package/dist/cli-name.js.map +1 -0
- package/dist/cli.d.ts +20 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +45 -289
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +60 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +56 -0
- package/dist/config.js.map +1 -1
- package/dist/keychain.d.ts +17 -0
- package/dist/keychain.d.ts.map +1 -1
- package/dist/keychain.js +36 -0
- package/dist/keychain.js.map +1 -1
- package/dist/login.d.ts +45 -1
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +91 -4
- package/dist/login.js.map +1 -1
- package/dist/program.d.ts +16 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +342 -0
- package/dist/program.js.map +1 -0
- package/dist/task-runner.d.ts +1 -1
- package/dist/task-runner.d.ts.map +1 -1
- package/dist/task-runner.js +40 -3
- package/dist/task-runner.js.map +1 -1
- package/dist/user-auth/device-login.d.ts +47 -0
- package/dist/user-auth/device-login.d.ts.map +1 -0
- package/dist/user-auth/device-login.js +189 -0
- package/dist/user-auth/device-login.js.map +1 -0
- package/dist/user-auth/liveness.d.ts +27 -0
- package/dist/user-auth/liveness.d.ts.map +1 -0
- package/dist/user-auth/liveness.js +128 -0
- package/dist/user-auth/liveness.js.map +1 -0
- package/dist/user-auth/session.d.ts +82 -0
- package/dist/user-auth/session.d.ts.map +1 -0
- package/dist/user-auth/session.js +172 -0
- package/dist/user-auth/session.js.map +1 -0
- package/dist/user-cli/chat.d.ts +44 -0
- package/dist/user-cli/chat.d.ts.map +1 -0
- package/dist/user-cli/chat.js +199 -0
- package/dist/user-cli/chat.js.map +1 -0
- package/dist/user-cli/client.d.ts +20 -0
- package/dist/user-cli/client.d.ts.map +1 -0
- package/dist/user-cli/client.js +60 -0
- package/dist/user-cli/client.js.map +1 -0
- package/dist/user-cli/tasks.d.ts +37 -0
- package/dist/user-cli/tasks.d.ts.map +1 -0
- package/dist/user-cli/tasks.js +131 -0
- package/dist/user-cli/tasks.js.map +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does this terminal's session still exist server-side? (AX-G3, gap G-16.)
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM. Revoking a CLI login on Settings → Sessions deletes the
|
|
5
|
+
* `auth.sessions` row and cascades its refresh-token family, so this machine can
|
|
6
|
+
* never mint another access token. But the access token already in the keychain
|
|
7
|
+
* stays cryptographically valid until it expires — and PostgREST, which every
|
|
8
|
+
* `acc task` read goes through, verifies a JWT's signature without ever looking at
|
|
9
|
+
* the session table. So a revoked terminal would keep reading the board for the
|
|
10
|
+
* rest of the token's life, which is up to an hour of a revoke not being a revoke.
|
|
11
|
+
*
|
|
12
|
+
* The refresh path already handles the other half: once the token IS stale,
|
|
13
|
+
* refreshUserSession gets a 4xx and raises SignedOutError. This module covers the
|
|
14
|
+
* window before that — one cheap read of /api/auth/session-status per command.
|
|
15
|
+
*
|
|
16
|
+
* FAIL OPEN, ON PURPOSE. Only a positive `live: false` signs the operator out.
|
|
17
|
+
* An unreachable control plane, a 5xx, a deployment that predates the endpoint
|
|
18
|
+
* (404), an unwired session directory, a token with no session claim — all of
|
|
19
|
+
* those leave the session alone. The alternative is a pooler hiccup logging out
|
|
20
|
+
* every terminal in the fleet at once, which is a worse failure than the one this
|
|
21
|
+
* module exists to fix. The only thing we treat as definitive besides `live:false`
|
|
22
|
+
* is a 401 whose reason is `invalid_token`: that is the auth authority itself
|
|
23
|
+
* saying this credential is not a credential.
|
|
24
|
+
*/
|
|
25
|
+
import { loadConfig } from "../config.js";
|
|
26
|
+
import { signInCommand } from "../cli-name.js";
|
|
27
|
+
import { accessTokenIsFresh, clearUserSession, cliUserAgent, loadUserSession, SignedOutError, } from "./session.js";
|
|
28
|
+
export const SESSION_STATUS_PATH = "/api/auth/session-status";
|
|
29
|
+
/**
|
|
30
|
+
* Map one HTTP answer onto a verdict. Pure, so every branch is asserted directly
|
|
31
|
+
* rather than through a fetch double.
|
|
32
|
+
*/
|
|
33
|
+
export function decideLiveness(status, body) {
|
|
34
|
+
const b = (body ?? {});
|
|
35
|
+
if (status === 401) {
|
|
36
|
+
// requireUser answers 401 with a discriminated reason. `invalid_token` is the
|
|
37
|
+
// authority rejecting the credential — definitive. `auth_unreachable` and the
|
|
38
|
+
// header-shape classes are not about this session at all.
|
|
39
|
+
const reason = typeof b.reason === "string" ? b.reason : "unauthenticated";
|
|
40
|
+
if (reason === "invalid_token")
|
|
41
|
+
return { live: false, verified: true, reason };
|
|
42
|
+
return { live: true, verified: false, reason };
|
|
43
|
+
}
|
|
44
|
+
if (status !== 200) {
|
|
45
|
+
// 404 = an older deployment without this endpoint. 5xx = the control plane.
|
|
46
|
+
// Neither is evidence about the session.
|
|
47
|
+
return { live: true, verified: false, reason: `http_${status}` };
|
|
48
|
+
}
|
|
49
|
+
if (b.live === false && b.verified === true) {
|
|
50
|
+
return { live: false, verified: true, reason: typeof b.reason === "string" ? b.reason : "revoked" };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
live: true,
|
|
54
|
+
verified: b.verified === true,
|
|
55
|
+
reason: typeof b.reason === "string" ? b.reason : "live",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Ask the control plane about this session. Never throws — a transport failure
|
|
59
|
+
* is an unverified verdict, not a sign-out. */
|
|
60
|
+
export async function probeUserSession(session, deps = {}) {
|
|
61
|
+
const doFetch = deps.fetch ?? fetch;
|
|
62
|
+
const base = loadConfig().publicUrl.replace(/\/+$/, "");
|
|
63
|
+
try {
|
|
64
|
+
const res = await doFetch(`${base}${SESSION_STATUS_PATH}`, {
|
|
65
|
+
method: "GET",
|
|
66
|
+
headers: {
|
|
67
|
+
Accept: "application/json",
|
|
68
|
+
Authorization: `Bearer ${session.access_token}`,
|
|
69
|
+
"User-Agent": deps.userAgent ?? cliUserAgent(),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const body = await res.json().catch(() => ({}));
|
|
73
|
+
return decideLiveness(res.status, body);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
return {
|
|
77
|
+
live: true,
|
|
78
|
+
verified: false,
|
|
79
|
+
reason: `unreachable: ${err.message?.slice(0, 120) ?? "error"}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** The message an operator sees when their terminal was revoked from the web. */
|
|
84
|
+
export function revokedMessage() {
|
|
85
|
+
return ("This terminal's ACC session is no longer valid — it was revoked from " +
|
|
86
|
+
`Settings → Sessions, or it expired. Run \`${signInCommand()}\` to sign in again.`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* One probe per process, so two calls inside a single command do not both go to
|
|
90
|
+
* the network. A new command is a new process, which is what "takes effect on its
|
|
91
|
+
* next call" means for a CLI.
|
|
92
|
+
*/
|
|
93
|
+
let inFlight = null;
|
|
94
|
+
/** Test seam: forget the memoized verdict. */
|
|
95
|
+
export function resetSessionLivenessProbe() {
|
|
96
|
+
inFlight = null;
|
|
97
|
+
}
|
|
98
|
+
async function runCheck(deps) {
|
|
99
|
+
const session = await loadUserSession();
|
|
100
|
+
// No session at all: the command's own "not signed in" error is the right
|
|
101
|
+
// message, and it does not need a network call to say it.
|
|
102
|
+
if (!session)
|
|
103
|
+
return;
|
|
104
|
+
// A stale token is about to be refreshed, and a refresh against a revoked
|
|
105
|
+
// session already fails with the same error. Probing would be a second network
|
|
106
|
+
// call to learn what the first one is about to tell us.
|
|
107
|
+
if (!accessTokenIsFresh(session, deps.now?.() ?? Date.now()))
|
|
108
|
+
return;
|
|
109
|
+
const verdict = await probeUserSession(session, deps);
|
|
110
|
+
if (verdict.live)
|
|
111
|
+
return;
|
|
112
|
+
// Drop the dead credential: leaving it would make every later command fail the
|
|
113
|
+
// same way, and would make `whoami` claim a session that no longer exists.
|
|
114
|
+
await clearUserSession();
|
|
115
|
+
throw new SignedOutError(revokedMessage());
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Called at the top of every person-lane command. Throws SignedOutError when the
|
|
119
|
+
* session has been revoked; returns silently in every other case, including every
|
|
120
|
+
* case where we could not tell.
|
|
121
|
+
*/
|
|
122
|
+
export function ensureUserSessionLive(deps = {}) {
|
|
123
|
+
// A rejected memo is deliberately kept: a second caller in the same process
|
|
124
|
+
// should see the same sign-out, not probe a session we already know is gone.
|
|
125
|
+
inFlight ??= runCheck(deps);
|
|
126
|
+
return inFlight;
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=liveness.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"liveness.js","sourceRoot":"","sources":["../../src/user-auth/liveness.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Keychain account for the person session. The runner's is "default". */
|
|
2
|
+
export declare const USER_SESSION_SLOT: "user";
|
|
3
|
+
/** Refresh this far ahead of expiry, so a long RPC never straddles it. */
|
|
4
|
+
export declare const REFRESH_SKEW_SECONDS = 120;
|
|
5
|
+
export interface StoredUserSession {
|
|
6
|
+
access_token: string;
|
|
7
|
+
refresh_token: string;
|
|
8
|
+
/** ISO-8601 instant at which access_token expires. */
|
|
9
|
+
access_expires_at: string;
|
|
10
|
+
user_id: string;
|
|
11
|
+
email: string | null;
|
|
12
|
+
/** Resolved by the exchange, so a fresh machine needs no connection env. */
|
|
13
|
+
supabase_url: string;
|
|
14
|
+
anon_key: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `acc-cli/1.2.3 (darwin; my-laptop)`. The hostname is filtered to header-safe
|
|
18
|
+
* characters — a hostname with a stray byte must not make an auth call throw
|
|
19
|
+
* where it would otherwise have succeeded.
|
|
20
|
+
*/
|
|
21
|
+
export declare function cliUserAgent(hostname?: string): string;
|
|
22
|
+
/** Raised when the session is gone for good — revoked, expired, or never there. */
|
|
23
|
+
export declare class SignedOutError extends Error {
|
|
24
|
+
constructor(message: string);
|
|
25
|
+
}
|
|
26
|
+
export declare function saveUserSession(session: StoredUserSession): Promise<void>;
|
|
27
|
+
export declare function loadUserSession(): Promise<StoredUserSession | null>;
|
|
28
|
+
export declare function clearUserSession(): Promise<boolean>;
|
|
29
|
+
/** The token payload GoTrue answers `/verify` and `/token` with. */
|
|
30
|
+
export interface GoTrueTokenResponse {
|
|
31
|
+
access_token?: string;
|
|
32
|
+
refresh_token?: string;
|
|
33
|
+
expires_in?: number;
|
|
34
|
+
expires_at?: number;
|
|
35
|
+
user?: {
|
|
36
|
+
id?: string;
|
|
37
|
+
email?: string | null;
|
|
38
|
+
} | null;
|
|
39
|
+
}
|
|
40
|
+
export interface GoTrueTarget {
|
|
41
|
+
supabaseUrl: string;
|
|
42
|
+
anonKey: string;
|
|
43
|
+
}
|
|
44
|
+
/** Injectable so the tests drive the rules without a network. */
|
|
45
|
+
export interface GoTrueDeps {
|
|
46
|
+
fetch?: typeof fetch;
|
|
47
|
+
now?: () => number;
|
|
48
|
+
userAgent?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Fold a token response into a session, or fail loudly. A response missing
|
|
52
|
+
* either token, or the user id, is not a session however cheerful its status
|
|
53
|
+
* code was — better to refuse than to persist a credential we cannot refresh.
|
|
54
|
+
*/
|
|
55
|
+
export declare function toStoredSession(json: GoTrueTokenResponse, target: GoTrueTarget, now: number): StoredUserSession;
|
|
56
|
+
/** Spend the one-time grant from /api/auth/device-code for a real session. */
|
|
57
|
+
export declare function verifyEmailOtp(target: GoTrueTarget, params: {
|
|
58
|
+
email: string;
|
|
59
|
+
token: string;
|
|
60
|
+
}, deps?: GoTrueDeps): Promise<StoredUserSession>;
|
|
61
|
+
/**
|
|
62
|
+
* Trade the refresh token for a fresh pair. A 4xx here is the revocation path,
|
|
63
|
+
* not an outage: GoTrue answers 400/401 for a token that was revoked (the
|
|
64
|
+
* sessions surface deleted the row) or already rotated, and both mean the same
|
|
65
|
+
* thing to the operator — sign in again.
|
|
66
|
+
*/
|
|
67
|
+
export declare function refreshUserSession(session: StoredUserSession, deps?: GoTrueDeps): Promise<StoredUserSession>;
|
|
68
|
+
export declare function accessTokenIsFresh(session: StoredUserSession, now?: number): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The accessor everything downstream should use: a live access token for the
|
|
71
|
+
* signed-in human, refreshed and re-persisted when it is close to expiry.
|
|
72
|
+
*
|
|
73
|
+
* The rotated successor is written back BEFORE the token is returned, because a
|
|
74
|
+
* caller that used a token whose refresh partner never landed would be one crash
|
|
75
|
+
* away from replaying a spent token — which GoTrue treats as reuse.
|
|
76
|
+
*
|
|
77
|
+
* A revoked session clears the keychain slot on the way out. Leaving a dead
|
|
78
|
+
* credential in the keychain would make every later command fail the same way
|
|
79
|
+
* with nothing to show for it; removing it makes `auth status` honest.
|
|
80
|
+
*/
|
|
81
|
+
export declare function getUserAccessToken(deps?: GoTrueDeps): Promise<string>;
|
|
82
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/user-auth/session.ts"],"names":[],"mappings":"AAmCA,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,EAAG,MAAe,CAAC;AAEjD,0EAA0E;AAC1E,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,sDAAsD;IACtD,iBAAiB,EAAE,MAAM,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,4EAA4E;IAC5E,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,QAAQ,GAAE,MAAsB,GAAG,MAAM,CAGrE;AAED,mFAAmF;AACnF,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,EAAE,MAAM;CAI5B;AAID,wBAAgB,eAAe,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAEzE;AAED,wBAAgB,eAAe,IAAI,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAEnE;AAED,wBAAgB,gBAAgB,IAAI,OAAO,CAAC,OAAO,CAAC,CAEnD;AAID,oEAAoE;AACpE,MAAM,WAAW,mBAAmB;IAClC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;CACtD;AAED,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,iEAAiE;AACjE,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA4BD;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,YAAY,EACpB,GAAG,EAAE,MAAM,GACV,iBAAiB,CAoBnB;AAED,8EAA8E;AAC9E,wBAAsB,cAAc,CAClC,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EACxC,IAAI,GAAE,UAAe,GACpB,OAAO,CAAC,iBAAiB,CAAC,CAW5B;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,GAAE,UAAe,GACpB,OAAO,CAAC,iBAAiB,CAAC,CAiB5B;AAED,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,iBAAiB,EAC1B,GAAG,GAAE,MAAmB,GACvB,OAAO,CAIT;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,GAAE,UAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CAc/E"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PERSON's CLI session (AX-G1 DEVICE-FLOW-GENERALIZE, gap G-16).
|
|
3
|
+
*
|
|
4
|
+
* `acc-runner login` enrols a MACHINE: it ends holding an ACC-signed JWT that
|
|
5
|
+
* impersonates its owner, plus a runner row in the fleet. This module is the
|
|
6
|
+
* other thing a terminal might want to be — a human, signed in as themselves,
|
|
7
|
+
* with no runner attached. It is the credential AX-G2 acts under.
|
|
8
|
+
*
|
|
9
|
+
* WHAT THE CREDENTIAL IS
|
|
10
|
+
* ----------------------------------------------------------------------------
|
|
11
|
+
* An ordinary Supabase session: the same access/refresh pair the user's browser
|
|
12
|
+
* holds, obtained by spending the one-time grant /api/auth/device-code hands
|
|
13
|
+
* back (see that file for why the CLI, not the server, spends it). Everything
|
|
14
|
+
* follows from that choice:
|
|
15
|
+
*
|
|
16
|
+
* * REFRESH is GoTrue's, not ACC's. `/auth/v1/token?grant_type=refresh_token`,
|
|
17
|
+
* with rotation — so the successor MUST be written back to the keychain on
|
|
18
|
+
* every refresh, or the next start replays a spent token.
|
|
19
|
+
* * REVOCATION is real and it is not ours. The session is a row in
|
|
20
|
+
* `auth.sessions`; Settings → Sessions deletes it and the delete cascades
|
|
21
|
+
* the refresh-token family. The next refresh here fails, and `signedOut`
|
|
22
|
+
* below is how that becomes "you were signed out" rather than a stack trace.
|
|
23
|
+
* * STORAGE is the OS keychain and nothing else. Its own account slot, so it
|
|
24
|
+
* cannot disturb the runner session's frozen entry.
|
|
25
|
+
*
|
|
26
|
+
* The USER AGENT is stamped deliberately. GoTrue records it on the session row,
|
|
27
|
+
* and api/auth/sessions-core.ts turns it into the device label on the sessions
|
|
28
|
+
* surface — so this string is the difference between "Unknown device" and
|
|
29
|
+
* "acc CLI · my-laptop" when the user decides what to revoke.
|
|
30
|
+
*/
|
|
31
|
+
import os from "node:os";
|
|
32
|
+
import { clearSlot, loadSlot, saveSlot } from "../keychain.js";
|
|
33
|
+
import { signInCommand } from "../cli-name.js";
|
|
34
|
+
import { PACKAGE_VERSION } from "../pkg-version.js";
|
|
35
|
+
/** Keychain account for the person session. The runner's is "default". */
|
|
36
|
+
export const USER_SESSION_SLOT = "user";
|
|
37
|
+
/** Refresh this far ahead of expiry, so a long RPC never straddles it. */
|
|
38
|
+
export const REFRESH_SKEW_SECONDS = 120;
|
|
39
|
+
/**
|
|
40
|
+
* `acc-cli/1.2.3 (darwin; my-laptop)`. The hostname is filtered to header-safe
|
|
41
|
+
* characters — a hostname with a stray byte must not make an auth call throw
|
|
42
|
+
* where it would otherwise have succeeded.
|
|
43
|
+
*/
|
|
44
|
+
export function cliUserAgent(hostname = os.hostname()) {
|
|
45
|
+
const host = hostname.replace(/[^\w.-]+/g, "-").slice(0, 63) || "unknown-host";
|
|
46
|
+
return `acc-cli/${PACKAGE_VERSION} (${process.platform}; ${host})`;
|
|
47
|
+
}
|
|
48
|
+
/** Raised when the session is gone for good — revoked, expired, or never there. */
|
|
49
|
+
export class SignedOutError extends Error {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "SignedOutError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// ── Keychain ───────────────────────────────────────────────────────────────
|
|
56
|
+
export function saveUserSession(session) {
|
|
57
|
+
return saveSlot(USER_SESSION_SLOT, session);
|
|
58
|
+
}
|
|
59
|
+
export function loadUserSession() {
|
|
60
|
+
return loadSlot(USER_SESSION_SLOT);
|
|
61
|
+
}
|
|
62
|
+
export function clearUserSession() {
|
|
63
|
+
return clearSlot(USER_SESSION_SLOT);
|
|
64
|
+
}
|
|
65
|
+
async function postGoTrue(target, path, body, deps) {
|
|
66
|
+
const doFetch = deps.fetch ?? fetch;
|
|
67
|
+
const res = await doFetch(`${target.supabaseUrl.replace(/\/+$/, "")}${path}`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
apikey: target.anonKey,
|
|
72
|
+
// Recorded on auth.sessions.user_agent — this is the device label.
|
|
73
|
+
"User-Agent": deps.userAgent ?? cliUserAgent(),
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify(body),
|
|
76
|
+
});
|
|
77
|
+
let json = {};
|
|
78
|
+
try {
|
|
79
|
+
json = (await res.json());
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* a non-JSON body is handled by the shape check below */
|
|
83
|
+
}
|
|
84
|
+
return { status: res.status, json };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Fold a token response into a session, or fail loudly. A response missing
|
|
88
|
+
* either token, or the user id, is not a session however cheerful its status
|
|
89
|
+
* code was — better to refuse than to persist a credential we cannot refresh.
|
|
90
|
+
*/
|
|
91
|
+
export function toStoredSession(json, target, now) {
|
|
92
|
+
const access = json.access_token;
|
|
93
|
+
const refresh = json.refresh_token;
|
|
94
|
+
const userId = json.user?.id;
|
|
95
|
+
if (!access || !refresh || !userId) {
|
|
96
|
+
throw new Error("auth response did not contain a usable session");
|
|
97
|
+
}
|
|
98
|
+
const expiresAt = typeof json.expires_at === "number"
|
|
99
|
+
? json.expires_at * 1000
|
|
100
|
+
: now + (typeof json.expires_in === "number" ? json.expires_in : 3600) * 1000;
|
|
101
|
+
return {
|
|
102
|
+
access_token: access,
|
|
103
|
+
refresh_token: refresh,
|
|
104
|
+
access_expires_at: new Date(expiresAt).toISOString(),
|
|
105
|
+
user_id: userId,
|
|
106
|
+
email: json.user?.email ?? null,
|
|
107
|
+
supabase_url: target.supabaseUrl,
|
|
108
|
+
anon_key: target.anonKey,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Spend the one-time grant from /api/auth/device-code for a real session. */
|
|
112
|
+
export async function verifyEmailOtp(target, params, deps = {}) {
|
|
113
|
+
const { status, json } = await postGoTrue(target, "/auth/v1/verify", { type: "email", email: params.email, token: params.token }, deps);
|
|
114
|
+
if (status !== 200) {
|
|
115
|
+
throw new Error(`sign-in grant was rejected (HTTP ${status})`);
|
|
116
|
+
}
|
|
117
|
+
return toStoredSession(json, target, deps.now?.() ?? Date.now());
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Trade the refresh token for a fresh pair. A 4xx here is the revocation path,
|
|
121
|
+
* not an outage: GoTrue answers 400/401 for a token that was revoked (the
|
|
122
|
+
* sessions surface deleted the row) or already rotated, and both mean the same
|
|
123
|
+
* thing to the operator — sign in again.
|
|
124
|
+
*/
|
|
125
|
+
export async function refreshUserSession(session, deps = {}) {
|
|
126
|
+
const target = { supabaseUrl: session.supabase_url, anonKey: session.anon_key };
|
|
127
|
+
const { status, json } = await postGoTrue(target, "/auth/v1/token?grant_type=refresh_token", { refresh_token: session.refresh_token }, deps);
|
|
128
|
+
if (status >= 400 && status < 500) {
|
|
129
|
+
throw new SignedOutError(`This CLI session was revoked or expired. Run \`${signInCommand()}\`.`);
|
|
130
|
+
}
|
|
131
|
+
if (status !== 200) {
|
|
132
|
+
throw new Error(`token refresh failed (HTTP ${status})`);
|
|
133
|
+
}
|
|
134
|
+
return toStoredSession(json, target, deps.now?.() ?? Date.now());
|
|
135
|
+
}
|
|
136
|
+
export function accessTokenIsFresh(session, now = Date.now()) {
|
|
137
|
+
const expiry = new Date(session.access_expires_at).getTime();
|
|
138
|
+
if (!Number.isFinite(expiry))
|
|
139
|
+
return false;
|
|
140
|
+
return expiry - now > REFRESH_SKEW_SECONDS * 1000;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The accessor everything downstream should use: a live access token for the
|
|
144
|
+
* signed-in human, refreshed and re-persisted when it is close to expiry.
|
|
145
|
+
*
|
|
146
|
+
* The rotated successor is written back BEFORE the token is returned, because a
|
|
147
|
+
* caller that used a token whose refresh partner never landed would be one crash
|
|
148
|
+
* away from replaying a spent token — which GoTrue treats as reuse.
|
|
149
|
+
*
|
|
150
|
+
* A revoked session clears the keychain slot on the way out. Leaving a dead
|
|
151
|
+
* credential in the keychain would make every later command fail the same way
|
|
152
|
+
* with nothing to show for it; removing it makes `auth status` honest.
|
|
153
|
+
*/
|
|
154
|
+
export async function getUserAccessToken(deps = {}) {
|
|
155
|
+
const session = await loadUserSession();
|
|
156
|
+
if (!session) {
|
|
157
|
+
throw new SignedOutError(`Not signed in. Run \`${signInCommand()}\`.`);
|
|
158
|
+
}
|
|
159
|
+
if (accessTokenIsFresh(session, deps.now?.() ?? Date.now()))
|
|
160
|
+
return session.access_token;
|
|
161
|
+
try {
|
|
162
|
+
const rotated = await refreshUserSession(session, deps);
|
|
163
|
+
await saveUserSession(rotated);
|
|
164
|
+
return rotated.access_token;
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (err instanceof SignedOutError)
|
|
168
|
+
await clearUserSession();
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/user-auth/session.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type UserApiDeps } from "./client.js";
|
|
2
|
+
export interface ChatOptions {
|
|
3
|
+
conversation?: string;
|
|
4
|
+
json?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface SseEvent {
|
|
7
|
+
event: string;
|
|
8
|
+
data: unknown;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Split a raw SSE buffer into whole events, returning the unconsumed tail.
|
|
12
|
+
*
|
|
13
|
+
* The tail matters: a chunk boundary lands mid-frame constantly on a token
|
|
14
|
+
* stream, and an implementation that parsed each chunk independently would drop
|
|
15
|
+
* every event unlucky enough to straddle one.
|
|
16
|
+
*/
|
|
17
|
+
export declare function drainSseBuffer(buffer: string): {
|
|
18
|
+
events: SseEvent[];
|
|
19
|
+
rest: string;
|
|
20
|
+
};
|
|
21
|
+
/** What one completed turn tells the caller, so a REPL can continue the thread. */
|
|
22
|
+
export interface TurnResult {
|
|
23
|
+
conversationId: string | null;
|
|
24
|
+
text: string;
|
|
25
|
+
queued: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Send one message and render the reply. Exported separately from the command so
|
|
29
|
+
* the REPL below can loop over it while holding the conversation id, and so the
|
|
30
|
+
* tests can drive a whole turn through a fetch double.
|
|
31
|
+
*/
|
|
32
|
+
export declare function streamTurn(message: string, opts: ChatOptions, deps?: UserApiDeps & {
|
|
33
|
+
write?: (chunk: string) => void;
|
|
34
|
+
}): Promise<TurnResult>;
|
|
35
|
+
/**
|
|
36
|
+
* `acc chat "question"` for one shot, `echo q | acc chat` for a pipe, and a bare
|
|
37
|
+
* `acc chat` on a terminal for a REPL that holds the conversation open. The REPL
|
|
38
|
+
* is not a nicety: without it every follow-up question would need the operator
|
|
39
|
+
* to copy a conversation id back out of the previous line.
|
|
40
|
+
*/
|
|
41
|
+
export declare function chatCommand(words: string[], opts?: ChatOptions, deps?: UserApiDeps & {
|
|
42
|
+
write?: (chunk: string) => void;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
//# sourceMappingURL=chat.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/user-cli/chat.ts"],"names":[],"mappings":"AAkBA,OAAO,EAAa,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAG1D,MAAM,WAAW,WAAW;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAwBnF;AAED,mFAAmF;AACnF,MAAM,WAAW,UAAU;IACzB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;CACjB;AAMD;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,EACjB,IAAI,GAAE,WAAW,GAAG;IAAE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GAC3D,OAAO,CAAC,UAAU,CAAC,CAkFrB;AASD;;;;;GAKG;AACH,wBAAsB,WAAW,CAC/B,KAAK,EAAE,MAAM,EAAE,EACf,IAAI,GAAE,WAAgB,EACtB,IAAI,GAAE,WAAW,GAAG;IAAE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GAC3D,OAAO,CAAC,IAAI,CAAC,CAoCf"}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `acc chat` — Ask-ACC from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Posts to the same `/api/chat/stream` endpoint the browser uses, authenticated
|
|
5
|
+
* with the operator's own session, and renders the SSE stream as it arrives.
|
|
6
|
+
* Using the shared endpoint rather than calling a model directly is the whole
|
|
7
|
+
* point: the tool catalog, the delegation ceiling, the capability manifest, the
|
|
8
|
+
* per-turn tool cap and the chat audit trail are all server-side, so a terminal
|
|
9
|
+
* turn is governed exactly like a browser turn and cannot become a side door.
|
|
10
|
+
*
|
|
11
|
+
* TWO SHAPES OF ANSWER. With a BYOK key the endpoint streams `token` events and
|
|
12
|
+
* the reply appears here word by word. Without one, an eligible turn is ENQUEUED
|
|
13
|
+
* for a companion runner to drain and the stream ends after `queued` — there is
|
|
14
|
+
* no inline answer to print, so this says so and hands back the conversation id
|
|
15
|
+
* rather than pretending the turn failed.
|
|
16
|
+
*/
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import { createInterface } from "node:readline/promises";
|
|
19
|
+
import { userFetch } from "./client.js";
|
|
20
|
+
import { cliName } from "../cli-name.js";
|
|
21
|
+
/**
|
|
22
|
+
* Split a raw SSE buffer into whole events, returning the unconsumed tail.
|
|
23
|
+
*
|
|
24
|
+
* The tail matters: a chunk boundary lands mid-frame constantly on a token
|
|
25
|
+
* stream, and an implementation that parsed each chunk independently would drop
|
|
26
|
+
* every event unlucky enough to straddle one.
|
|
27
|
+
*/
|
|
28
|
+
export function drainSseBuffer(buffer) {
|
|
29
|
+
const events = [];
|
|
30
|
+
let rest = buffer;
|
|
31
|
+
for (;;) {
|
|
32
|
+
const boundary = rest.indexOf("\n\n");
|
|
33
|
+
if (boundary === -1)
|
|
34
|
+
break;
|
|
35
|
+
const frame = rest.slice(0, boundary);
|
|
36
|
+
rest = rest.slice(boundary + 2);
|
|
37
|
+
let event = "message";
|
|
38
|
+
const dataLines = [];
|
|
39
|
+
for (const line of frame.split("\n")) {
|
|
40
|
+
if (line.startsWith("event:"))
|
|
41
|
+
event = line.slice(6).trim();
|
|
42
|
+
else if (line.startsWith("data:"))
|
|
43
|
+
dataLines.push(line.slice(5).trim());
|
|
44
|
+
}
|
|
45
|
+
if (dataLines.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
let data = dataLines.join("\n");
|
|
48
|
+
try {
|
|
49
|
+
data = JSON.parse(dataLines.join("\n"));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* a non-JSON payload is passed through as the raw string */
|
|
53
|
+
}
|
|
54
|
+
events.push({ event, data });
|
|
55
|
+
}
|
|
56
|
+
return { events, rest };
|
|
57
|
+
}
|
|
58
|
+
function asRecord(data) {
|
|
59
|
+
return data && typeof data === "object" ? data : {};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Send one message and render the reply. Exported separately from the command so
|
|
63
|
+
* the REPL below can loop over it while holding the conversation id, and so the
|
|
64
|
+
* tests can drive a whole turn through a fetch double.
|
|
65
|
+
*/
|
|
66
|
+
export async function streamTurn(message, opts, deps = {}) {
|
|
67
|
+
const say = deps.log ?? ((line) => console.log(line));
|
|
68
|
+
const write = deps.write ?? ((chunk) => process.stdout.write(chunk));
|
|
69
|
+
const res = await userFetch("/api/chat/stream", { body: { message, conversation_id: opts.conversation } }, deps);
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
// The endpoint's own body carries the actionable reason (no provider, no
|
|
72
|
+
// membership, rate limit); a bare status code would send the operator to
|
|
73
|
+
// the logs for something they can read here.
|
|
74
|
+
let detail = `HTTP ${res.status}`;
|
|
75
|
+
try {
|
|
76
|
+
const body = asRecord(await res.json());
|
|
77
|
+
if (typeof body.error === "string")
|
|
78
|
+
detail = `${body.error} (HTTP ${res.status})`;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* keep the status-only detail */
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`Chat request failed: ${detail}`);
|
|
84
|
+
}
|
|
85
|
+
if (!res.body)
|
|
86
|
+
throw new Error("Chat response carried no stream.");
|
|
87
|
+
let conversationId = opts.conversation ?? null;
|
|
88
|
+
let text = "";
|
|
89
|
+
let queued = false;
|
|
90
|
+
let buffer = "";
|
|
91
|
+
let wroteAnything = false;
|
|
92
|
+
const decoder = new TextDecoder();
|
|
93
|
+
const reader = res.body.getReader();
|
|
94
|
+
for (;;) {
|
|
95
|
+
const { done, value } = await reader.read();
|
|
96
|
+
if (done)
|
|
97
|
+
break;
|
|
98
|
+
buffer += decoder.decode(value, { stream: true });
|
|
99
|
+
const drained = drainSseBuffer(buffer);
|
|
100
|
+
buffer = drained.rest;
|
|
101
|
+
for (const { event, data } of drained.events) {
|
|
102
|
+
const payload = asRecord(data);
|
|
103
|
+
switch (event) {
|
|
104
|
+
case "conversation":
|
|
105
|
+
if (typeof payload.id === "string")
|
|
106
|
+
conversationId = payload.id;
|
|
107
|
+
break;
|
|
108
|
+
case "token": {
|
|
109
|
+
const chunk = typeof payload.text === "string" ? payload.text : "";
|
|
110
|
+
text += chunk;
|
|
111
|
+
if (!opts.json) {
|
|
112
|
+
write(chunk);
|
|
113
|
+
wroteAnything = wroteAnything || chunk.length > 0;
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "tool_call":
|
|
118
|
+
if (!opts.json)
|
|
119
|
+
say(chalk.gray(`\n · ${String(payload.name ?? "tool")}…`));
|
|
120
|
+
break;
|
|
121
|
+
case "queued":
|
|
122
|
+
case "turn":
|
|
123
|
+
queued = true;
|
|
124
|
+
break;
|
|
125
|
+
case "error":
|
|
126
|
+
throw new Error(String(payload.message ?? "chat stream failed"));
|
|
127
|
+
case "done":
|
|
128
|
+
if (typeof payload.conversation_id === "string")
|
|
129
|
+
conversationId = payload.conversation_id;
|
|
130
|
+
if (payload.queued === true)
|
|
131
|
+
queued = true;
|
|
132
|
+
break;
|
|
133
|
+
default:
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (wroteAnything)
|
|
139
|
+
write("\n");
|
|
140
|
+
if (opts.json) {
|
|
141
|
+
say(JSON.stringify({ conversation_id: conversationId, text, queued }, null, 2));
|
|
142
|
+
}
|
|
143
|
+
else if (queued && !text) {
|
|
144
|
+
say(chalk.yellow("· Queued to a companion runner — no inline answer. Open the conversation in ACC to read the reply."));
|
|
145
|
+
}
|
|
146
|
+
return { conversationId, text, queued };
|
|
147
|
+
}
|
|
148
|
+
/** Read a piped message when one was not passed as an argument. */
|
|
149
|
+
async function readStdin() {
|
|
150
|
+
const chunks = [];
|
|
151
|
+
for await (const chunk of process.stdin)
|
|
152
|
+
chunks.push(Buffer.from(chunk));
|
|
153
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* `acc chat "question"` for one shot, `echo q | acc chat` for a pipe, and a bare
|
|
157
|
+
* `acc chat` on a terminal for a REPL that holds the conversation open. The REPL
|
|
158
|
+
* is not a nicety: without it every follow-up question would need the operator
|
|
159
|
+
* to copy a conversation id back out of the previous line.
|
|
160
|
+
*/
|
|
161
|
+
export async function chatCommand(words, opts = {}, deps = {}) {
|
|
162
|
+
const say = deps.log ?? ((line) => console.log(line));
|
|
163
|
+
const inline = words.join(" ").trim();
|
|
164
|
+
if (inline) {
|
|
165
|
+
const result = await streamTurn(inline, opts, deps);
|
|
166
|
+
if (!opts.json && result.conversationId) {
|
|
167
|
+
say(chalk.gray(`\nContinue: ${cliName()} chat --conversation ${result.conversationId}`));
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!process.stdin.isTTY) {
|
|
172
|
+
const piped = await readStdin();
|
|
173
|
+
if (!piped)
|
|
174
|
+
throw new Error("No message given (pass one as an argument or pipe it in).");
|
|
175
|
+
await streamTurn(piped, opts, deps);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
say(chalk.bold("Ask ACC. Blank line or Ctrl-D to exit."));
|
|
179
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
180
|
+
// Ctrl-D closes the input without settling the pending question(), which would
|
|
181
|
+
// hang the process on an invisible prompt. Racing the close event turns EOF
|
|
182
|
+
// into the same clean exit a blank line gives.
|
|
183
|
+
const closed = new Promise((resolve) => rl.once("close", () => resolve(null)));
|
|
184
|
+
let conversation = opts.conversation;
|
|
185
|
+
try {
|
|
186
|
+
for (;;) {
|
|
187
|
+
const answer = await Promise.race([rl.question(chalk.cyan("\n› ")), closed]);
|
|
188
|
+
const line = (answer ?? "").trim();
|
|
189
|
+
if (!line)
|
|
190
|
+
break;
|
|
191
|
+
const result = await streamTurn(line, { ...opts, conversation }, deps);
|
|
192
|
+
conversation = result.conversationId ?? conversation;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
rl.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
//# sourceMappingURL=chat.js.map
|