iterate 0.2.7 → 0.4.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 +173 -81
- package/dist/api.d.ts +643 -0
- package/dist/api.mjs +0 -0
- package/dist/app-server.d.ts +51 -0
- package/dist/app-server.mjs +481 -0
- package/dist/app-server.mjs.map +1 -0
- package/dist/app-session.d.ts +49 -0
- package/dist/app-session.mjs +235 -0
- package/dist/app-session.mjs.map +1 -0
- package/dist/app.d.ts +29 -0
- package/dist/app.mjs +180 -0
- package/dist/app.mjs.map +1 -0
- package/dist/client/live-state.d.ts +63 -0
- package/dist/client/oauth.d.ts +17 -0
- package/dist/client/react.d.ts +77 -0
- package/dist/client/socket.d.ts +7 -0
- package/dist/client.mjs +156 -0
- package/dist/client.mjs.map +1 -0
- package/dist/expression.d.ts +88 -0
- package/dist/expression.mjs +301 -0
- package/dist/expression.mjs.map +1 -0
- package/dist/lib-BWr-5mFO.mjs +36 -0
- package/dist/lib-BWr-5mFO.mjs.map +1 -0
- package/dist/lib.d.ts +70 -0
- package/dist/lib.mjs +228 -0
- package/dist/lib.mjs.map +1 -0
- package/dist/node.d.ts +15 -0
- package/dist/node.mjs +47 -0
- package/dist/node.mjs.map +1 -0
- package/dist/oauth-scopes.d.ts +32 -0
- package/dist/oauth-scopes.mjs +40 -0
- package/dist/oauth-scopes.mjs.map +1 -0
- package/dist/oauth.mjs +41 -0
- package/dist/oauth.mjs.map +1 -0
- package/dist/principal.d.ts +8 -0
- package/dist/principal.mjs +8 -0
- package/dist/principal.mjs.map +1 -0
- package/dist/project-ingress.d.ts +58 -0
- package/dist/project-ingress.mjs +104 -0
- package/dist/project-ingress.mjs.map +1 -0
- package/dist/react.mjs +285 -0
- package/dist/react.mjs.map +1 -0
- package/dist/sdk/auth.d.ts +25 -0
- package/dist/sdk/index.d.ts +155 -0
- package/dist/sdk/record-pipelined-steps.d.ts +19 -0
- package/dist/sdk.mjs +245 -0
- package/dist/sdk.mjs.map +1 -0
- package/dist/stream/processor.d.ts +383 -0
- package/dist/stream/processor.mjs +605 -0
- package/dist/stream/processor.mjs.map +1 -0
- package/dist/stream/run.d.ts +61 -0
- package/dist/stream/run.mjs +45 -0
- package/dist/stream/run.mjs.map +1 -0
- package/dist/stream/test-support.d.ts +45 -0
- package/dist/stream/test-support.mjs +196 -0
- package/dist/stream/test-support.mjs.map +1 -0
- package/dist/usingCtx-inzbY1Qz.mjs +57 -0
- package/package.json +93 -30
- package/bin/iterate.js +0 -86
- package/dist/cli-DMS4kJph.mjs +0 -868
- package/dist/cli-DMS4kJph.mjs.map +0 -1
- package/dist/config-DtnR7Lv7.mjs +0 -170
- package/dist/config-DtnR7Lv7.mjs.map +0 -1
- package/dist/index.d.mts +0 -5
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -8
- package/dist/index.mjs.map +0 -1
- package/dist/stream-tui/agent-chat-terminal.d.mts +0 -1
- package/dist/stream-tui/agent-chat-terminal.mjs +0 -933
- package/dist/stream-tui/agent-chat-terminal.mjs.map +0 -1
- package/dist/worker.d.mts +0 -33
- package/dist/worker.mjs +0 -18
- package/dist/worker.mjs.map +0 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
export type BrowserHost = {
|
|
3
|
+
origin: string;
|
|
4
|
+
issuer: string;
|
|
5
|
+
resource: string;
|
|
6
|
+
scopes: string[];
|
|
7
|
+
client?: {
|
|
8
|
+
id?: string;
|
|
9
|
+
name: string;
|
|
10
|
+
logoUri: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/** Each app binds this same class. Token exchange and logout use the issuer's
|
|
14
|
+
* public protocol, so separately deployed apps need no platform bindings.
|
|
15
|
+
* The input gate serializes refresh and logout across tabs. */
|
|
16
|
+
export declare class BrowserSession extends DurableObject {
|
|
17
|
+
#private;
|
|
18
|
+
begin(host: BrowserHost, next: string): Promise<string>;
|
|
19
|
+
/** The issuer's redirect back to `/.auth/callback`, its query string. oauth4webapi validates it
|
|
20
|
+
* (the state matches this browser's pending flow, the `iss` matches the issuer, no `error`), then
|
|
21
|
+
* exchanges the code for tokens. */
|
|
22
|
+
complete(callbackQuery: string): Promise<{
|
|
23
|
+
error: string;
|
|
24
|
+
next?: undefined;
|
|
25
|
+
} | {
|
|
26
|
+
next: string;
|
|
27
|
+
error?: undefined;
|
|
28
|
+
}>;
|
|
29
|
+
bearer(): Promise<string | null>;
|
|
30
|
+
/** THE ISSUER THIS SESSION IS BOUND TO and the resource its tokens are for — written once at
|
|
31
|
+
* `begin`, never steered by a request: the app's `/api` proxy, its login probe and its logout read
|
|
32
|
+
* them from here, so a browser connected to one issuer can only ever spend its credential there.
|
|
33
|
+
* Null when no session was begun. */
|
|
34
|
+
host(): Promise<{
|
|
35
|
+
issuer: string;
|
|
36
|
+
resource: string;
|
|
37
|
+
} | null>;
|
|
38
|
+
scopes(): Promise<string[]>;
|
|
39
|
+
/** Public client metadata chosen by the app before consent; never a browser-supplied claim. */
|
|
40
|
+
client(): Promise<{
|
|
41
|
+
id?: string;
|
|
42
|
+
name: string;
|
|
43
|
+
logoUri: string;
|
|
44
|
+
} | undefined>;
|
|
45
|
+
/** A verified 401 means this local credential no longer grants access. */
|
|
46
|
+
discard(): Promise<void>;
|
|
47
|
+
end(): Promise<void>;
|
|
48
|
+
alarm(): Promise<void>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { isLocalOrigin } from "./lib.mjs";
|
|
2
|
+
import { OAuthScopes } from "./oauth-scopes.mjs";
|
|
3
|
+
import { t as _usingCtx } from "./usingCtx-inzbY1Qz.mjs";
|
|
4
|
+
import { authorizationCodeRequest, authorizationServer } from "./oauth.mjs";
|
|
5
|
+
import { DurableObject } from "cloudflare:workers";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { newHttpBatchRpcSession } from "capnweb";
|
|
8
|
+
import * as oauth from "oauth4webapi";
|
|
9
|
+
//#region src/app-session.ts
|
|
10
|
+
/** Each app binds this same class. Token exchange and logout use the issuer's
|
|
11
|
+
* public protocol, so separately deployed apps need no platform bindings.
|
|
12
|
+
* The input gate serializes refresh and logout across tabs. */
|
|
13
|
+
var BrowserSession = class extends DurableObject {
|
|
14
|
+
begin(host, next) {
|
|
15
|
+
return this.#serial(async () => {
|
|
16
|
+
if (await this.ctx.storage.get("session")) throw new Error("Browser session already exists");
|
|
17
|
+
const origin = new URL(host.origin);
|
|
18
|
+
const local = isLocalOrigin(host.origin);
|
|
19
|
+
if (origin.protocol !== "https:" && !local) throw new Error("Browser login requires HTTPS");
|
|
20
|
+
let clientId = host.client?.id || `${host.origin}/.auth/client.json`;
|
|
21
|
+
if (local) {
|
|
22
|
+
const response = await fetch(`${host.issuer}/oauth2/register`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
client_name: host.client?.name || origin.host,
|
|
27
|
+
client_uri: host.origin,
|
|
28
|
+
logo_uri: host.client?.logoUri,
|
|
29
|
+
redirect_uris: [`${host.origin}/.auth/callback`],
|
|
30
|
+
token_endpoint_auth_method: "none",
|
|
31
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
32
|
+
response_types: ["code"]
|
|
33
|
+
}),
|
|
34
|
+
signal: AbortSignal.timeout(1e4)
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) throw new Error(`Local client registration failed (${response.status})`);
|
|
37
|
+
clientId = z.object({ client_id: z.string().min(1) }).parse(await response.json()).client_id;
|
|
38
|
+
}
|
|
39
|
+
const { url, state, verifier } = await authorizationCodeRequest({
|
|
40
|
+
issuer: host.issuer,
|
|
41
|
+
clientId,
|
|
42
|
+
redirectUri: `${host.origin}/.auth/callback`,
|
|
43
|
+
resources: [host.resource],
|
|
44
|
+
scopes: host.scopes
|
|
45
|
+
});
|
|
46
|
+
const data = {
|
|
47
|
+
...host,
|
|
48
|
+
next,
|
|
49
|
+
clientId,
|
|
50
|
+
phase: "pending",
|
|
51
|
+
state,
|
|
52
|
+
verifier,
|
|
53
|
+
until: Date.now() + 10 * 6e4
|
|
54
|
+
};
|
|
55
|
+
await this.ctx.storage.put("session", data);
|
|
56
|
+
await this.ctx.storage.setAlarm(data.until);
|
|
57
|
+
return url.href;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** The issuer's redirect back to `/.auth/callback`, its query string. oauth4webapi validates it
|
|
61
|
+
* (the state matches this browser's pending flow, the `iss` matches the issuer, no `error`), then
|
|
62
|
+
* exchanges the code for tokens. */
|
|
63
|
+
complete(callbackQuery) {
|
|
64
|
+
return this.#serial(async () => {
|
|
65
|
+
const data = await this.ctx.storage.get("session");
|
|
66
|
+
if (!data || data.phase !== "pending" || data.until <= Date.now()) return { error: "This sign-in expired or does not match this browser. Start sign-in again." };
|
|
67
|
+
const as = authorizationServer(data.issuer);
|
|
68
|
+
const client = { client_id: data.clientId };
|
|
69
|
+
let callback;
|
|
70
|
+
try {
|
|
71
|
+
callback = oauth.validateAuthResponse(as, client, new URLSearchParams(callbackQuery), data.state);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error instanceof oauth.AuthorizationResponseError) {
|
|
74
|
+
await this.#clear();
|
|
75
|
+
return { error: "Authorization was declined." };
|
|
76
|
+
}
|
|
77
|
+
return { error: "This sign-in expired or does not match this browser. Start sign-in again." };
|
|
78
|
+
}
|
|
79
|
+
const started = Date.now();
|
|
80
|
+
let tokens;
|
|
81
|
+
try {
|
|
82
|
+
const response = await oauth.authorizationCodeGrantRequest(as, client, oauth.None(), callback, `${data.origin}/.auth/callback`, data.verifier, this.#tokenOptions(data));
|
|
83
|
+
tokens = await oauth.processAuthorizationCodeResponse(as, client, response);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
await this.#endOnDeadGrant(error);
|
|
86
|
+
return { error: "Sign-in could not complete. Start sign-in again." };
|
|
87
|
+
}
|
|
88
|
+
await this.#activate(data, tokens, started);
|
|
89
|
+
return { next: data.next };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
bearer() {
|
|
93
|
+
return this.#serial(() => this.#bearer());
|
|
94
|
+
}
|
|
95
|
+
/** THE ISSUER THIS SESSION IS BOUND TO and the resource its tokens are for — written once at
|
|
96
|
+
* `begin`, never steered by a request: the app's `/api` proxy, its login probe and its logout read
|
|
97
|
+
* them from here, so a browser connected to one issuer can only ever spend its credential there.
|
|
98
|
+
* Null when no session was begun. */
|
|
99
|
+
async host() {
|
|
100
|
+
const data = await this.ctx.storage.get("session");
|
|
101
|
+
return data ? {
|
|
102
|
+
issuer: data.issuer,
|
|
103
|
+
resource: data.resource
|
|
104
|
+
} : null;
|
|
105
|
+
}
|
|
106
|
+
async scopes() {
|
|
107
|
+
const data = await this.ctx.storage.get("session");
|
|
108
|
+
return data?.phase === "active" ? data.scopes : [];
|
|
109
|
+
}
|
|
110
|
+
/** Public client metadata chosen by the app before consent; never a browser-supplied claim. */
|
|
111
|
+
async client() {
|
|
112
|
+
const data = await this.ctx.storage.get("session");
|
|
113
|
+
return data?.phase === "active" ? data.client : void 0;
|
|
114
|
+
}
|
|
115
|
+
/** A verified 401 means this local credential no longer grants access. */
|
|
116
|
+
discard() {
|
|
117
|
+
return this.#serial(() => this.#clear());
|
|
118
|
+
}
|
|
119
|
+
end() {
|
|
120
|
+
return this.#serial(async () => {
|
|
121
|
+
const token = await this.#bearer();
|
|
122
|
+
const data = await this.ctx.storage.get("session");
|
|
123
|
+
if (token && data) {
|
|
124
|
+
const probe = await fetch(new Request(data.resource, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
127
|
+
body: "",
|
|
128
|
+
signal: AbortSignal.timeout(1e4)
|
|
129
|
+
}));
|
|
130
|
+
await probe.body?.cancel();
|
|
131
|
+
if (probe.status !== 401) try {
|
|
132
|
+
var _usingCtx$1 = _usingCtx();
|
|
133
|
+
if (!probe.ok) throw new Error(`Sign-out could not reach Iterate (${probe.status}). Try again.`);
|
|
134
|
+
const api = _usingCtx$1.u(newHttpBatchRpcSession(new Request(data.resource, {
|
|
135
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
136
|
+
signal: AbortSignal.timeout(1e4)
|
|
137
|
+
})));
|
|
138
|
+
await _usingCtx$1.u(api.authenticate({ type: "bearer" })).logout();
|
|
139
|
+
} catch (_) {
|
|
140
|
+
_usingCtx$1.e = _;
|
|
141
|
+
} finally {
|
|
142
|
+
_usingCtx$1.d();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
await this.#clear();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async alarm() {
|
|
149
|
+
await this.#clear();
|
|
150
|
+
}
|
|
151
|
+
async #bearer() {
|
|
152
|
+
const data = await this.ctx.storage.get("session");
|
|
153
|
+
if (!data || data.phase !== "active") return null;
|
|
154
|
+
if (data.until <= Date.now()) {
|
|
155
|
+
await this.#clear();
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
if (data.expiresAt > Date.now() + 3e4) return data.accessToken;
|
|
159
|
+
const as = authorizationServer(data.issuer);
|
|
160
|
+
const client = { client_id: data.clientId };
|
|
161
|
+
const started = Date.now();
|
|
162
|
+
let tokens;
|
|
163
|
+
try {
|
|
164
|
+
const response = await oauth.refreshTokenGrantRequest(as, client, oauth.None(), data.refreshToken, this.#tokenOptions(data));
|
|
165
|
+
tokens = await oauth.processRefreshTokenResponse(as, client, response);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
await this.#endOnDeadGrant(error);
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return (await this.#activate(data, tokens, started)).accessToken;
|
|
171
|
+
}
|
|
172
|
+
/** The token request's shared options: the audience (RFC 8707 resource), a bounded timeout, and —
|
|
173
|
+
* only for a LOCAL APP (`begin` already admits http there) — oauth4webapi's opt-out of its
|
|
174
|
+
* HTTPS-only default. Keyed on the app's origin, never the issuer's: an issuer a person typed can
|
|
175
|
+
* not talk a deployed app into sending its codes in the clear. */
|
|
176
|
+
#tokenOptions(data) {
|
|
177
|
+
const options = {
|
|
178
|
+
additionalParameters: { resource: data.resource },
|
|
179
|
+
signal: AbortSignal.timeout(1e4)
|
|
180
|
+
};
|
|
181
|
+
if (isLocalOrigin(data.origin)) options[oauth.allowInsecureRequests] = true;
|
|
182
|
+
return options;
|
|
183
|
+
}
|
|
184
|
+
/** Write the active session from a token response. A refresh reuses the prior refresh token when
|
|
185
|
+
* the issuer does not rotate it. */
|
|
186
|
+
async #activate(data, tokens, started) {
|
|
187
|
+
const refreshToken = tokens.refresh_token || (data.phase === "active" ? data.refreshToken : void 0);
|
|
188
|
+
if (!refreshToken) throw new Error("Iterate returned no refresh token.");
|
|
189
|
+
const { origin, issuer, resource, clientId, next } = data;
|
|
190
|
+
const stored = {
|
|
191
|
+
origin,
|
|
192
|
+
issuer,
|
|
193
|
+
resource,
|
|
194
|
+
clientId,
|
|
195
|
+
client: data.client,
|
|
196
|
+
next,
|
|
197
|
+
phase: "active",
|
|
198
|
+
scopes: tokens.scope ? OAuthScopes.parse(tokens.scope.split(" ").filter(Boolean)) : data.scopes,
|
|
199
|
+
accessToken: tokens.access_token,
|
|
200
|
+
refreshToken,
|
|
201
|
+
expiresAt: started + (tokens.expires_in ?? 0) * 1e3,
|
|
202
|
+
until: data.phase === "pending" ? started + 720 * 36e5 : data.until
|
|
203
|
+
};
|
|
204
|
+
await this.ctx.storage.put("session", stored);
|
|
205
|
+
await this.ctx.storage.setAlarm(stored.until);
|
|
206
|
+
return stored;
|
|
207
|
+
}
|
|
208
|
+
/** A dead code or refresh token (`invalid_grant`) ends the session; other failures are transient.
|
|
209
|
+
* A token endpoint that answered anything but its token — an OAuth error body, or a response
|
|
210
|
+
* oauth4webapi could not read as one (an uncaught 500) — throws one message naming its status,
|
|
211
|
+
* which reads the same on the far side of the Durable Object's RPC. */
|
|
212
|
+
async #endOnDeadGrant(error) {
|
|
213
|
+
if (error instanceof oauth.ResponseBodyError && error.error === "invalid_grant") {
|
|
214
|
+
await this.#clear();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const status = error instanceof oauth.ResponseBodyError ? error.status : error instanceof oauth.OperationProcessingError && error.cause instanceof Response ? error.cause.status : null;
|
|
218
|
+
throw status === null ? error : /* @__PURE__ */ new Error(`Iterate token exchange failed (${status}). Try again.`);
|
|
219
|
+
}
|
|
220
|
+
/** An operation failure must not reset the DO or fail other tabs' requests. */
|
|
221
|
+
#serial(work) {
|
|
222
|
+
return this.ctx.blockConcurrencyWhile(() => work().then((value) => ({ value }), (error) => ({ error }))).then((result) => {
|
|
223
|
+
if ("error" in result) throw result.error;
|
|
224
|
+
return result.value;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async #clear() {
|
|
228
|
+
await this.ctx.storage.deleteAll();
|
|
229
|
+
await this.ctx.storage.deleteAlarm();
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
//#endregion
|
|
233
|
+
export { BrowserSession };
|
|
234
|
+
|
|
235
|
+
//# sourceMappingURL=app-session.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app-session.mjs","names":["#serial","#clear","#tokenOptions","#endOnDeadGrant","#activate","#bearer"],"sources":["../src/app-session.ts"],"sourcesContent":["import { DurableObject } from \"cloudflare:workers\";\nimport { newHttpBatchRpcSession } from \"capnweb\";\nimport * as oauth from \"oauth4webapi\";\nimport { z } from \"zod\";\nimport { authorizationCodeRequest, authorizationServer } from \"./client/oauth.ts\";\nimport { OAuthScopes } from \"./oauth-scopes.ts\";\nimport { isLocalOrigin } from \"./lib.ts\";\nimport type { IterateApi } from \"./api.ts\";\n\nexport type BrowserHost = {\n origin: string;\n issuer: string;\n resource: string;\n scopes: string[];\n client?: { id?: string; name: string; logoUri: string };\n};\ntype Base = BrowserHost & { clientId: string; next: string; until: number };\ntype Pending = Base & { phase: \"pending\"; state: string; verifier: string };\ntype Active = Base & {\n phase: \"active\";\n accessToken: string;\n refreshToken: string;\n expiresAt: number;\n};\ntype StoredSession = Pending | Active;\n\n/** Each app binds this same class. Token exchange and logout use the issuer's\n * public protocol, so separately deployed apps need no platform bindings.\n * The input gate serializes refresh and logout across tabs. */\nexport class BrowserSession extends DurableObject {\n begin(host: BrowserHost, next: string) {\n return this.#serial(async () => {\n if (await this.ctx.storage.get(\"session\")) throw new Error(\"Browser session already exists\");\n const origin = new URL(host.origin);\n const local = isLocalOrigin(host.origin);\n if (origin.protocol !== \"https:\" && !local) throw new Error(\"Browser login requires HTTPS\");\n let clientId = host.client?.id || `${host.origin}/.auth/client.json`;\n if (local) {\n const response = await fetch(`${host.issuer}/oauth2/register`, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n client_name: host.client?.name || origin.host,\n client_uri: host.origin,\n logo_uri: host.client?.logoUri,\n redirect_uris: [`${host.origin}/.auth/callback`],\n token_endpoint_auth_method: \"none\",\n grant_types: [\"authorization_code\", \"refresh_token\"],\n response_types: [\"code\"],\n }),\n signal: AbortSignal.timeout(10_000),\n });\n if (!response.ok) throw new Error(`Local client registration failed (${response.status})`);\n clientId = z\n .object({ client_id: z.string().min(1) })\n .parse(await response.json()).client_id;\n }\n const { url, state, verifier } = await authorizationCodeRequest({\n issuer: host.issuer,\n clientId,\n redirectUri: `${host.origin}/.auth/callback`,\n resources: [host.resource],\n scopes: host.scopes,\n });\n const data: Pending = {\n ...host,\n next,\n clientId,\n phase: \"pending\",\n state,\n verifier,\n until: Date.now() + 10 * 60_000,\n };\n await this.ctx.storage.put(\"session\", data);\n await this.ctx.storage.setAlarm(data.until);\n return url.href;\n });\n }\n\n /** The issuer's redirect back to `/.auth/callback`, its query string. oauth4webapi validates it\n * (the state matches this browser's pending flow, the `iss` matches the issuer, no `error`), then\n * exchanges the code for tokens. */\n complete(callbackQuery: string) {\n return this.#serial(async () => {\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n if (!data || data.phase !== \"pending\" || data.until <= Date.now())\n return {\n error: \"This sign-in expired or does not match this browser. Start sign-in again.\",\n };\n const as = authorizationServer(data.issuer);\n const client: oauth.Client = { client_id: data.clientId };\n let callback: URLSearchParams;\n try {\n callback = oauth.validateAuthResponse(\n as,\n client,\n new URLSearchParams(callbackQuery),\n data.state,\n );\n } catch (error) {\n // A provider `error=` (the person declined) ends the flow; a state/iss mismatch leaves the\n // pending flow to be retried from a genuine browser.\n if (error instanceof oauth.AuthorizationResponseError) {\n await this.#clear();\n return { error: \"Authorization was declined.\" };\n }\n return {\n error: \"This sign-in expired or does not match this browser. Start sign-in again.\",\n };\n }\n const started = Date.now();\n let tokens: oauth.TokenEndpointResponse;\n try {\n const response = await oauth.authorizationCodeGrantRequest(\n as,\n client,\n oauth.None(),\n callback,\n `${data.origin}/.auth/callback`,\n data.verifier,\n this.#tokenOptions(data),\n );\n tokens = await oauth.processAuthorizationCodeResponse(as, client, response);\n } catch (error) {\n await this.#endOnDeadGrant(error);\n return { error: \"Sign-in could not complete. Start sign-in again.\" };\n }\n await this.#activate(data, tokens, started);\n return { next: data.next };\n });\n }\n\n bearer() {\n return this.#serial(() => this.#bearer());\n }\n /** THE ISSUER THIS SESSION IS BOUND TO and the resource its tokens are for — written once at\n * `begin`, never steered by a request: the app's `/api` proxy, its login probe and its logout read\n * them from here, so a browser connected to one issuer can only ever spend its credential there.\n * Null when no session was begun. */\n async host(): Promise<{ issuer: string; resource: string } | null> {\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n return data ? { issuer: data.issuer, resource: data.resource } : null;\n }\n async scopes() {\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n return data?.phase === \"active\" ? data.scopes : [];\n }\n /** Public client metadata chosen by the app before consent; never a browser-supplied claim. */\n async client() {\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n return data?.phase === \"active\" ? data.client : undefined;\n }\n /** A verified 401 means this local credential no longer grants access. */\n discard() {\n return this.#serial(() => this.#clear());\n }\n\n end() {\n return this.#serial(async () => {\n const token = await this.#bearer();\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n if (token && data) {\n // Probe classifies a revoked/expired credential without interpreting RPC error text.\n const probe = await fetch(\n new Request(data.resource, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${token}` },\n body: \"\",\n signal: AbortSignal.timeout(10_000),\n }),\n );\n await probe.body?.cancel();\n if (probe.status !== 401) {\n if (!probe.ok)\n throw new Error(`Sign-out could not reach Iterate (${probe.status}). Try again.`);\n // oxlint-disable-next-line iterate/no-capnweb-http-batch -- A bounded logout command returns no live capabilities.\n using api = newHttpBatchRpcSession<IterateApi>(\n new Request(data.resource, {\n headers: { Authorization: `Bearer ${token}` },\n signal: AbortSignal.timeout(10_000),\n }),\n );\n // `authenticate(...)` returns a SessionRpcTarget STUB — its own RPC result to dispose (the\n // capnweb README's `using authedApi = api.authenticate(...)`); disposing only `api` leaves\n // it dangling and workerd warns the result was never disposed.\n using session = api.authenticate({ type: \"bearer\" });\n await session.logout();\n }\n }\n // Network failures preserve the session and are shown to the caller.\n // Successful logout has written the issuer's durable revocation marker.\n await this.#clear();\n });\n }\n\n async alarm() {\n // Pending flows expire in ten minutes; active grants have the same absolute\n // thirty-day lifetime at the issuer (which also ends one unused for a week: its\n // refresh then answers `invalid_grant`). No refresh token survives this bound.\n await this.#clear();\n }\n async #bearer() {\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n if (!data || data.phase !== \"active\") return null;\n if (data.until <= Date.now()) {\n await this.#clear();\n return null;\n }\n if (data.expiresAt > Date.now() + 30_000) return data.accessToken;\n const as = authorizationServer(data.issuer);\n const client: oauth.Client = { client_id: data.clientId };\n const started = Date.now();\n let tokens: oauth.TokenEndpointResponse;\n try {\n const response = await oauth.refreshTokenGrantRequest(\n as,\n client,\n oauth.None(),\n data.refreshToken,\n this.#tokenOptions(data),\n );\n tokens = await oauth.processRefreshTokenResponse(as, client, response);\n } catch (error) {\n await this.#endOnDeadGrant(error);\n return null;\n }\n return (await this.#activate(data, tokens, started)).accessToken;\n }\n\n /** The token request's shared options: the audience (RFC 8707 resource), a bounded timeout, and —\n * only for a LOCAL APP (`begin` already admits http there) — oauth4webapi's opt-out of its\n * HTTPS-only default. Keyed on the app's origin, never the issuer's: an issuer a person typed can\n * not talk a deployed app into sending its codes in the clear. */\n #tokenOptions(data: StoredSession): oauth.TokenEndpointRequestOptions {\n const options: oauth.TokenEndpointRequestOptions = {\n additionalParameters: { resource: data.resource },\n signal: AbortSignal.timeout(10_000),\n };\n if (isLocalOrigin(data.origin)) options[oauth.allowInsecureRequests] = true;\n return options;\n }\n\n /** Write the active session from a token response. A refresh reuses the prior refresh token when\n * the issuer does not rotate it. */\n async #activate(\n data: StoredSession,\n tokens: oauth.TokenEndpointResponse,\n started: number,\n ): Promise<Active> {\n const refreshToken =\n tokens.refresh_token || (data.phase === \"active\" ? data.refreshToken : undefined);\n if (!refreshToken) throw new Error(\"Iterate returned no refresh token.\");\n const { origin, issuer, resource, clientId, next } = data;\n const stored: Active = {\n origin,\n issuer,\n resource,\n clientId,\n client: data.client,\n next,\n phase: \"active\",\n // An omitted (or empty) `scope` means unchanged (RFC 6749 §5.1) — keep what the grant already\n // holds rather than silently narrow to `iterate`.\n scopes: tokens.scope\n ? OAuthScopes.parse(tokens.scope.split(\" \").filter(Boolean))\n : data.scopes,\n accessToken: tokens.access_token,\n refreshToken,\n expiresAt: started + (tokens.expires_in ?? 0) * 1000,\n until: data.phase === \"pending\" ? started + 30 * 24 * 3600_000 : data.until,\n };\n await this.ctx.storage.put(\"session\", stored);\n await this.ctx.storage.setAlarm(stored.until);\n return stored;\n }\n\n /** A dead code or refresh token (`invalid_grant`) ends the session; other failures are transient.\n * A token endpoint that answered anything but its token — an OAuth error body, or a response\n * oauth4webapi could not read as one (an uncaught 500) — throws one message naming its status,\n * which reads the same on the far side of the Durable Object's RPC. */\n async #endOnDeadGrant(error: unknown): Promise<void> {\n if (error instanceof oauth.ResponseBodyError && error.error === \"invalid_grant\") {\n await this.#clear();\n return;\n }\n const status =\n error instanceof oauth.ResponseBodyError\n ? error.status\n : error instanceof oauth.OperationProcessingError && error.cause instanceof Response\n ? error.cause.status\n : null;\n throw status === null\n ? error\n : new Error(`Iterate token exchange failed (${status}). Try again.`);\n }\n /** An operation failure must not reset the DO or fail other tabs' requests. */\n #serial<T>(work: () => Promise<T>): Promise<T> {\n return this.ctx\n .blockConcurrencyWhile(() =>\n work().then(\n (value) => ({ value }),\n (error: unknown) => ({ error }),\n ),\n )\n .then((result) => {\n if (\"error\" in result) throw result.error;\n return result.value;\n });\n }\n\n async #clear() {\n await this.ctx.storage.deleteAll();\n await this.ctx.storage.deleteAlarm();\n }\n}\n"],"mappings":";;;;;;;;;;;;AA6BA,IAAa,iBAAb,cAAoC,cAAc;CAChD,MAAM,MAAmB,MAAc;EACrC,OAAO,KAAKA,QAAQ,YAAY;GAC9B,IAAI,MAAM,KAAK,IAAI,QAAQ,IAAI,SAAS,GAAG,MAAM,IAAI,MAAM,gCAAgC;GAC3F,MAAM,SAAS,IAAI,IAAI,KAAK,MAAM;GAClC,MAAM,QAAQ,cAAc,KAAK,MAAM;GACvC,IAAI,OAAO,aAAa,YAAY,CAAC,OAAO,MAAM,IAAI,MAAM,8BAA8B;GAC1F,IAAI,WAAW,KAAK,QAAQ,MAAM,GAAG,KAAK,OAAO;GACjD,IAAI,OAAO;IACT,MAAM,WAAW,MAAM,MAAM,GAAG,KAAK,OAAO,mBAAmB;KAC7D,QAAQ;KACR,SAAS,EAAE,gBAAgB,mBAAmB;KAC9C,MAAM,KAAK,UAAU;MACnB,aAAa,KAAK,QAAQ,QAAQ,OAAO;MACzC,YAAY,KAAK;MACjB,UAAU,KAAK,QAAQ;MACvB,eAAe,CAAC,GAAG,KAAK,OAAO,gBAAgB;MAC/C,4BAA4B;MAC5B,aAAa,CAAC,sBAAsB,eAAe;MACnD,gBAAgB,CAAC,MAAM;KACzB,CAAC;KACD,QAAQ,YAAY,QAAQ,GAAM;IACpC,CAAC;IACD,IAAI,CAAC,SAAS,IAAI,MAAM,IAAI,MAAM,qCAAqC,SAAS,OAAO,EAAE;IACzF,WAAW,EACR,OAAO,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CACxC,MAAM,MAAM,SAAS,KAAK,CAAC,CAAC,CAAC;GAClC;GACA,MAAM,EAAE,KAAK,OAAO,aAAa,MAAM,yBAAyB;IAC9D,QAAQ,KAAK;IACb;IACA,aAAa,GAAG,KAAK,OAAO;IAC5B,WAAW,CAAC,KAAK,QAAQ;IACzB,QAAQ,KAAK;GACf,CAAC;GACD,MAAM,OAAgB;IACpB,GAAG;IACH;IACA;IACA,OAAO;IACP;IACA;IACA,OAAO,KAAK,IAAI,IAAI,KAAK;GAC3B;GACA,MAAM,KAAK,IAAI,QAAQ,IAAI,WAAW,IAAI;GAC1C,MAAM,KAAK,IAAI,QAAQ,SAAS,KAAK,KAAK;GAC1C,OAAO,IAAI;EACb,CAAC;CACH;;;;CAKA,SAAS,eAAuB;EAC9B,OAAO,KAAKA,QAAQ,YAAY;GAC9B,MAAM,OAAO,MAAM,KAAK,IAAI,QAAQ,IAAmB,SAAS;GAChE,IAAI,CAAC,QAAQ,KAAK,UAAU,aAAa,KAAK,SAAS,KAAK,IAAI,GAC9D,OAAO,EACL,OAAO,4EACT;GACF,MAAM,KAAK,oBAAoB,KAAK,MAAM;GAC1C,MAAM,SAAuB,EAAE,WAAW,KAAK,SAAS;GACxD,IAAI;GACJ,IAAI;IACF,WAAW,MAAM,qBACf,IACA,QACA,IAAI,gBAAgB,aAAa,GACjC,KAAK,KACP;GACF,SAAS,OAAO;IAGd,IAAI,iBAAiB,MAAM,4BAA4B;KACrD,MAAM,KAAKC,OAAO;KAClB,OAAO,EAAE,OAAO,8BAA8B;IAChD;IACA,OAAO,EACL,OAAO,4EACT;GACF;GACA,MAAM,UAAU,KAAK,IAAI;GACzB,IAAI;GACJ,IAAI;IACF,MAAM,WAAW,MAAM,MAAM,8BAC3B,IACA,QACA,MAAM,KAAK,GACX,UACA,GAAG,KAAK,OAAO,kBACf,KAAK,UACL,KAAKC,cAAc,IAAI,CACzB;IACA,SAAS,MAAM,MAAM,iCAAiC,IAAI,QAAQ,QAAQ;GAC5E,SAAS,OAAO;IACd,MAAM,KAAKC,gBAAgB,KAAK;IAChC,OAAO,EAAE,OAAO,mDAAmD;GACrE;GACA,MAAM,KAAKC,UAAU,MAAM,QAAQ,OAAO;GAC1C,OAAO,EAAE,MAAM,KAAK,KAAK;EAC3B,CAAC;CACH;CAEA,SAAS;EACP,OAAO,KAAKJ,cAAc,KAAKK,QAAQ,CAAC;CAC1C;;;;;CAKA,MAAM,OAA6D;EACjE,MAAM,OAAO,MAAM,KAAK,IAAI,QAAQ,IAAmB,SAAS;EAChE,OAAO,OAAO;GAAE,QAAQ,KAAK;GAAQ,UAAU,KAAK;EAAS,IAAI;CACnE;CACA,MAAM,SAAS;EACb,MAAM,OAAO,MAAM,KAAK,IAAI,QAAQ,IAAmB,SAAS;EAChE,OAAO,MAAM,UAAU,WAAW,KAAK,SAAS,CAAC;CACnD;;CAEA,MAAM,SAAS;EACb,MAAM,OAAO,MAAM,KAAK,IAAI,QAAQ,IAAmB,SAAS;EAChE,OAAO,MAAM,UAAU,WAAW,KAAK,SAAS,KAAA;CAClD;;CAEA,UAAU;EACR,OAAO,KAAKL,cAAc,KAAKC,OAAO,CAAC;CACzC;CAEA,MAAM;EACJ,OAAO,KAAKD,QAAQ,YAAY;GAC9B,MAAM,QAAQ,MAAM,KAAKK,QAAQ;GACjC,MAAM,OAAO,MAAM,KAAK,IAAI,QAAQ,IAAmB,SAAS;GAChE,IAAI,SAAS,MAAM;IAEjB,MAAM,QAAQ,MAAM,MAClB,IAAI,QAAQ,KAAK,UAAU;KACzB,QAAQ;KACR,SAAS,EAAE,eAAe,UAAU,QAAQ;KAC5C,MAAM;KACN,QAAQ,YAAY,QAAQ,GAAM;IACpC,CAAC,CACH;IACA,MAAM,MAAM,MAAM,OAAO;IACzB,IAAI,MAAM,WAAW,KAAK,IAAA;;KACxB,IAAI,CAAC,MAAM,IACT,MAAM,IAAI,MAAM,qCAAqC,MAAM,OAAO,cAAc;KAElF,MAAM,MAAA,YAAA,EAAM,uBACV,IAAI,QAAQ,KAAK,UAAU;MACzB,SAAS,EAAE,eAAe,UAAU,QAAQ;MAC5C,QAAQ,YAAY,QAAQ,GAAM;KACpC,CAAC,CACH,CAAA;KAKA,MAAA,YAAA,EADgB,IAAI,aAAa,EAAE,MAAM,SAAS,CAAC,CACvC,CAAC,CAAC,OAAO;;;;;;GAEzB;GAGA,MAAM,KAAKJ,OAAO;EACpB,CAAC;CACH;CAEA,MAAM,QAAQ;EAIZ,MAAM,KAAKA,OAAO;CACpB;CACA,MAAMI,UAAU;EACd,MAAM,OAAO,MAAM,KAAK,IAAI,QAAQ,IAAmB,SAAS;EAChE,IAAI,CAAC,QAAQ,KAAK,UAAU,UAAU,OAAO;EAC7C,IAAI,KAAK,SAAS,KAAK,IAAI,GAAG;GAC5B,MAAM,KAAKJ,OAAO;GAClB,OAAO;EACT;EACA,IAAI,KAAK,YAAY,KAAK,IAAI,IAAI,KAAQ,OAAO,KAAK;EACtD,MAAM,KAAK,oBAAoB,KAAK,MAAM;EAC1C,MAAM,SAAuB,EAAE,WAAW,KAAK,SAAS;EACxD,MAAM,UAAU,KAAK,IAAI;EACzB,IAAI;EACJ,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,yBAC3B,IACA,QACA,MAAM,KAAK,GACX,KAAK,cACL,KAAKC,cAAc,IAAI,CACzB;GACA,SAAS,MAAM,MAAM,4BAA4B,IAAI,QAAQ,QAAQ;EACvE,SAAS,OAAO;GACd,MAAM,KAAKC,gBAAgB,KAAK;GAChC,OAAO;EACT;EACA,QAAQ,MAAM,KAAKC,UAAU,MAAM,QAAQ,OAAO,EAAA,CAAG;CACvD;;;;;CAMA,cAAc,MAAwD;EACpE,MAAM,UAA6C;GACjD,sBAAsB,EAAE,UAAU,KAAK,SAAS;GAChD,QAAQ,YAAY,QAAQ,GAAM;EACpC;EACA,IAAI,cAAc,KAAK,MAAM,GAAG,QAAQ,MAAM,yBAAyB;EACvE,OAAO;CACT;;;CAIA,MAAMA,UACJ,MACA,QACA,SACiB;EACjB,MAAM,eACJ,OAAO,kBAAkB,KAAK,UAAU,WAAW,KAAK,eAAe,KAAA;EACzE,IAAI,CAAC,cAAc,MAAM,IAAI,MAAM,oCAAoC;EACvE,MAAM,EAAE,QAAQ,QAAQ,UAAU,UAAU,SAAS;EACrD,MAAM,SAAiB;GACrB;GACA;GACA;GACA;GACA,QAAQ,KAAK;GACb;GACA,OAAO;GAGP,QAAQ,OAAO,QACX,YAAY,MAAM,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO,CAAC,IACzD,KAAK;GACT,aAAa,OAAO;GACpB;GACA,WAAW,WAAW,OAAO,cAAc,KAAK;GAChD,OAAO,KAAK,UAAU,YAAY,UAAU,MAAU,OAAW,KAAK;EACxE;EACA,MAAM,KAAK,IAAI,QAAQ,IAAI,WAAW,MAAM;EAC5C,MAAM,KAAK,IAAI,QAAQ,SAAS,OAAO,KAAK;EAC5C,OAAO;CACT;;;;;CAMA,MAAMD,gBAAgB,OAA+B;EACnD,IAAI,iBAAiB,MAAM,qBAAqB,MAAM,UAAU,iBAAiB;GAC/E,MAAM,KAAKF,OAAO;GAClB;EACF;EACA,MAAM,SACJ,iBAAiB,MAAM,oBACnB,MAAM,SACN,iBAAiB,MAAM,4BAA4B,MAAM,iBAAiB,WACxE,MAAM,MAAM,SACZ;EACR,MAAM,WAAW,OACb,wBACA,IAAI,MAAM,kCAAkC,OAAO,cAAc;CACvE;;CAEA,QAAW,MAAoC;EAC7C,OAAO,KAAK,IACT,4BACC,KAAK,CAAC,CAAC,MACJ,WAAW,EAAE,MAAM,KACnB,WAAoB,EAAE,MAAM,EAC/B,CACF,CAAC,CACA,MAAM,WAAW;GAChB,IAAI,WAAW,QAAQ,MAAM,OAAO;GACpC,OAAO,OAAO;EAChB,CAAC;CACL;CAEA,MAAMA,SAAS;EACb,MAAM,KAAK,IAAI,QAAQ,UAAU;EACjC,MAAM,KAAK,IAAI,QAAQ,YAAY;CACrC;AACF"}
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type RpcStub } from "capnweb";
|
|
2
|
+
import type { IterateSessionApi } from "./api.ts";
|
|
3
|
+
/** What `authenticate` resolves to: the session as a capnweb stub (pipelined; disposable) and the
|
|
4
|
+
* bootstrap info it answered with. Declared, so the package's declarations stay serializable. */
|
|
5
|
+
export type AuthenticatedApp = {
|
|
6
|
+
api: RpcStub<IterateSessionApi>;
|
|
7
|
+
info: ReturnType<IterateSessionApi["info"]>;
|
|
8
|
+
/** The page names a project this sign-in does not include — its `/projects/<ref>` is absent from
|
|
9
|
+
* `api.projects.list()`: leave for `/.auth/login`, whose page offers to sign in again (the
|
|
10
|
+
* project ticked at consent, or another account) and returns to this very URL. Never settles,
|
|
11
|
+
* like `authenticate`'s own leave for login. */
|
|
12
|
+
signInFor(project: string): Promise<never>;
|
|
13
|
+
};
|
|
14
|
+
export type IterateClient = {
|
|
15
|
+
authenticate(next?: string): Promise<AuthenticatedApp>;
|
|
16
|
+
};
|
|
17
|
+
/** Create once per app — a TanStack route's client-only `beforeLoad`, or a plain page's entry.
|
|
18
|
+
* Every loader and action of the page shares the returned public RPC session.
|
|
19
|
+
*
|
|
20
|
+
* Connecting tries for a while (client/socket.ts: ≈16 s of attempts) before an error reaches the
|
|
21
|
+
* page — a phone waking up or a flapping tunnel is not a reason to show "connection failed". And
|
|
22
|
+
* the `api` the page holds is a proxy to the CURRENT connection: when the socket closes, the next
|
|
23
|
+
* call opens a fresh one and pipelines onto it, so a dropped connection costs a reconnect, not the
|
|
24
|
+
* page. A reconnect the platform refuses (the session ended elsewhere) rejects that call; the
|
|
25
|
+
* page's retry runs `authenticate` again — a fresh one, the socket's close forgot the last — whose
|
|
26
|
+
* probe sends the browser to log in. */
|
|
27
|
+
export declare function createIterateClient(options?: {
|
|
28
|
+
scopes?: string[];
|
|
29
|
+
}): IterateClient;
|
package/dist/app.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { OAuthScopes } from "./oauth-scopes.mjs";
|
|
2
|
+
import { newWebSocketRpcSession } from "capnweb";
|
|
3
|
+
//#region src/client/socket.ts
|
|
4
|
+
/** Open a WebSocket, trying again for a while when the connection fails: a phone waking up, a
|
|
5
|
+
* tunnel flapping, a cold edge — the things a first attempt trips over. Resolves with the socket
|
|
6
|
+
* once it is OPEN; rejects with the last failure after the last attempt. The delays are the
|
|
7
|
+
* waits BETWEEN attempts (the first is immediate).
|
|
8
|
+
*
|
|
9
|
+
* Every failed attempt is explained, never swallowed (docs/engineering-invariants.md): the error
|
|
10
|
+
* names the close code and reason the socket closed with before it opened, and each attempt that
|
|
11
|
+
* is tried again logs a `client.platform-failure-socket-open` warn first. The upgrade's HTTP status
|
|
12
|
+
* is not among them: the WebSocket API never exposes the handshake's response (a refused upgrade
|
|
13
|
+
* is a close 1006, by design — https://websockets.spec.whatwg.org/#feedback-from-the-protocol),
|
|
14
|
+
* so a non-standard `ErrorEvent.error` (Node's undici names "non-101 status code" there) is added
|
|
15
|
+
* when the runtime gives one. */
|
|
16
|
+
const RETRY_DELAYS_MS = [
|
|
17
|
+
250,
|
|
18
|
+
500,
|
|
19
|
+
1e3,
|
|
20
|
+
2e3,
|
|
21
|
+
4e3,
|
|
22
|
+
8e3
|
|
23
|
+
];
|
|
24
|
+
/** How long one attempt may take to open. A handshake that neither opens nor closes is a failed
|
|
25
|
+
* attempt too, closed and tried again like any other: without a bound the page waits on it for as
|
|
26
|
+
* long as the browser does, behind a spinner that never ends (a Dash spec sat 30 s after its
|
|
27
|
+
* sign-in's `POST /api` answered, with no `/api` upgrade ever reaching the Worker, 2026-09-24).
|
|
28
|
+
* A healthy upgrade opens in well under a second. */
|
|
29
|
+
const HANDSHAKE_TIMEOUT_MS = 1e4;
|
|
30
|
+
function openSocketWithRetry(url, options = {}) {
|
|
31
|
+
const delays = options.delaysMs || RETRY_DELAYS_MS;
|
|
32
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
|
|
33
|
+
const Socket = options.WebSocket || WebSocket;
|
|
34
|
+
const sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
35
|
+
const once = () => new Promise((resolve, reject) => {
|
|
36
|
+
const socket = new Socket(url);
|
|
37
|
+
let cause = "";
|
|
38
|
+
const timeout = setTimeout(() => {
|
|
39
|
+
settle();
|
|
40
|
+
socket.close();
|
|
41
|
+
reject(/* @__PURE__ */ new Error(`WebSocket did not open in ${handshakeTimeoutMs} ms`));
|
|
42
|
+
}, handshakeTimeoutMs);
|
|
43
|
+
function settle() {
|
|
44
|
+
clearTimeout(timeout);
|
|
45
|
+
socket.removeEventListener("open", opened);
|
|
46
|
+
socket.removeEventListener("close", failed);
|
|
47
|
+
socket.removeEventListener("error", errored);
|
|
48
|
+
}
|
|
49
|
+
function errored(event) {
|
|
50
|
+
const error = event.error;
|
|
51
|
+
if (error instanceof Error) cause = ` (${error.message})`;
|
|
52
|
+
}
|
|
53
|
+
function opened() {
|
|
54
|
+
settle();
|
|
55
|
+
resolve(socket);
|
|
56
|
+
}
|
|
57
|
+
function failed(event) {
|
|
58
|
+
settle();
|
|
59
|
+
reject(/* @__PURE__ */ new Error(`WebSocket connection failed: closed ${event.code}${event.reason ? ` "${event.reason}"` : ""} before it opened${cause}`));
|
|
60
|
+
}
|
|
61
|
+
socket.addEventListener("open", opened, { once: true });
|
|
62
|
+
socket.addEventListener("error", errored);
|
|
63
|
+
socket.addEventListener("close", failed, { once: true });
|
|
64
|
+
});
|
|
65
|
+
return (async () => {
|
|
66
|
+
for (let attempt = 1;; attempt++) try {
|
|
67
|
+
return await once();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const delay = delays[attempt - 1];
|
|
70
|
+
if (delay === void 0) throw error;
|
|
71
|
+
console.warn({
|
|
72
|
+
event: "client.platform-failure-socket-open",
|
|
73
|
+
url: String(url),
|
|
74
|
+
attempt,
|
|
75
|
+
attempts: delays.length + 1,
|
|
76
|
+
retryInMs: delay,
|
|
77
|
+
message: error.message
|
|
78
|
+
});
|
|
79
|
+
await sleep(delay);
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/app.ts
|
|
85
|
+
/** The browser leaves for the issuer's login (a document navigation) and this never settles — no
|
|
86
|
+
* framework in the loop: a TanStack `beforeLoad` awaiting it ends the way a thrown
|
|
87
|
+
* `redirect({ reloadDocument: true })` did, a plain page simply navigates. */
|
|
88
|
+
function leaveForLogin(login) {
|
|
89
|
+
window.location.assign(login);
|
|
90
|
+
return new Promise(() => {});
|
|
91
|
+
}
|
|
92
|
+
function connectionOn(socket) {
|
|
93
|
+
const iterate = newWebSocketRpcSession(socket);
|
|
94
|
+
return {
|
|
95
|
+
api: iterate.authenticate({ type: "from-server-cookie" }),
|
|
96
|
+
socket,
|
|
97
|
+
dispose: () => iterate[Symbol.dispose]()
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Create once per app — a TanStack route's client-only `beforeLoad`, or a plain page's entry.
|
|
101
|
+
* Every loader and action of the page shares the returned public RPC session.
|
|
102
|
+
*
|
|
103
|
+
* Connecting tries for a while (client/socket.ts: ≈16 s of attempts) before an error reaches the
|
|
104
|
+
* page — a phone waking up or a flapping tunnel is not a reason to show "connection failed". And
|
|
105
|
+
* the `api` the page holds is a proxy to the CURRENT connection: when the socket closes, the next
|
|
106
|
+
* call opens a fresh one and pipelines onto it, so a dropped connection costs a reconnect, not the
|
|
107
|
+
* page. A reconnect the platform refuses (the session ended elsewhere) rejects that call; the
|
|
108
|
+
* page's retry runs `authenticate` again — a fresh one, the socket's close forgot the last — whose
|
|
109
|
+
* probe sends the browser to log in. */
|
|
110
|
+
function createIterateClient(options = {}) {
|
|
111
|
+
const scopes = OAuthScopes.parse(options.scopes || []);
|
|
112
|
+
let live = null;
|
|
113
|
+
let connecting;
|
|
114
|
+
const socketUrl = () => {
|
|
115
|
+
const url = new URL("/api", window.location.href);
|
|
116
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
117
|
+
return url;
|
|
118
|
+
};
|
|
119
|
+
/** Adopt a connection as the live one until its socket closes. A close also forgets the settled
|
|
120
|
+
* `authenticate` — the next one probes `/api` again, so a session that ended elsewhere sends the
|
|
121
|
+
* browser to log in instead of a retry that can only fail. */
|
|
122
|
+
function adopt(socket) {
|
|
123
|
+
const connection = connectionOn(socket);
|
|
124
|
+
live = connection;
|
|
125
|
+
const forget = () => {
|
|
126
|
+
if (live !== connection) return;
|
|
127
|
+
live = null;
|
|
128
|
+
connecting = void 0;
|
|
129
|
+
};
|
|
130
|
+
const dispose = () => {
|
|
131
|
+
forget();
|
|
132
|
+
connection.dispose();
|
|
133
|
+
};
|
|
134
|
+
socket.addEventListener("close", () => {
|
|
135
|
+
forget();
|
|
136
|
+
window.removeEventListener("pagehide", dispose);
|
|
137
|
+
}, { once: true });
|
|
138
|
+
window.addEventListener("pagehide", dispose, { once: true });
|
|
139
|
+
return connection;
|
|
140
|
+
}
|
|
141
|
+
const api = new Proxy({}, { get(_target, property) {
|
|
142
|
+
const connection = live || adopt(new WebSocket(socketUrl()));
|
|
143
|
+
return Reflect.get(connection.api, property);
|
|
144
|
+
} });
|
|
145
|
+
const loginUrl = (params) => `/.auth/login?${new URLSearchParams({
|
|
146
|
+
scope: scopes.join(" "),
|
|
147
|
+
...params
|
|
148
|
+
})}`;
|
|
149
|
+
async function connect(next) {
|
|
150
|
+
const login = loginUrl({ next });
|
|
151
|
+
const probe = await fetch("/api", {
|
|
152
|
+
method: "POST",
|
|
153
|
+
body: "",
|
|
154
|
+
signal: AbortSignal.timeout(1e4)
|
|
155
|
+
});
|
|
156
|
+
await probe.body?.cancel();
|
|
157
|
+
if (probe.status === 401) return leaveForLogin(login);
|
|
158
|
+
if (!probe.ok) throw new Error(`iterate is unavailable (${probe.status}). Please retry.`);
|
|
159
|
+
if (!live) adopt(await openSocketWithRetry(socketUrl()));
|
|
160
|
+
return {
|
|
161
|
+
api,
|
|
162
|
+
info: await api.info(),
|
|
163
|
+
signInFor: (project) => leaveForLogin(loginUrl({
|
|
164
|
+
next: window.location.href,
|
|
165
|
+
project
|
|
166
|
+
}))
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { authenticate(next = "/") {
|
|
170
|
+
connecting ||= connect(next).catch((error) => {
|
|
171
|
+
connecting = void 0;
|
|
172
|
+
throw error;
|
|
173
|
+
});
|
|
174
|
+
return connecting;
|
|
175
|
+
} };
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
export { createIterateClient };
|
|
179
|
+
|
|
180
|
+
//# sourceMappingURL=app.mjs.map
|
package/dist/app.mjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app.mjs","names":[],"sources":["../src/client/socket.ts","../src/app.ts"],"sourcesContent":["/** Open a WebSocket, trying again for a while when the connection fails: a phone waking up, a\n * tunnel flapping, a cold edge — the things a first attempt trips over. Resolves with the socket\n * once it is OPEN; rejects with the last failure after the last attempt. The delays are the\n * waits BETWEEN attempts (the first is immediate).\n *\n * Every failed attempt is explained, never swallowed (docs/engineering-invariants.md): the error\n * names the close code and reason the socket closed with before it opened, and each attempt that\n * is tried again logs a `client.platform-failure-socket-open` warn first. The upgrade's HTTP status\n * is not among them: the WebSocket API never exposes the handshake's response (a refused upgrade\n * is a close 1006, by design — https://websockets.spec.whatwg.org/#feedback-from-the-protocol),\n * so a non-standard `ErrorEvent.error` (Node's undici names \"non-101 status code\" there) is added\n * when the runtime gives one. */\nconst RETRY_DELAYS_MS: readonly number[] = [250, 500, 1_000, 2_000, 4_000, 8_000];\n\n/** How long one attempt may take to open. A handshake that neither opens nor closes is a failed\n * attempt too, closed and tried again like any other: without a bound the page waits on it for as\n * long as the browser does, behind a spinner that never ends (a Dash spec sat 30 s after its\n * sign-in's `POST /api` answered, with no `/api` upgrade ever reaching the Worker, 2026-09-24).\n * A healthy upgrade opens in well under a second. */\nconst HANDSHAKE_TIMEOUT_MS = 10_000;\n\nexport function openSocketWithRetry(\n url: string | URL,\n options: {\n delaysMs?: readonly number[];\n handshakeTimeoutMs?: number;\n /** the constructor to use — a test's fake, `WebSocket` otherwise */\n WebSocket?: typeof WebSocket;\n sleep?: (ms: number) => Promise<void>;\n } = {},\n): Promise<WebSocket> {\n const delays = options.delaysMs || RETRY_DELAYS_MS;\n const handshakeTimeoutMs = options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;\n const Socket = options.WebSocket || WebSocket;\n const sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));\n const once = () =>\n new Promise<WebSocket>((resolve, reject) => {\n const socket = new Socket(url);\n let cause = \"\";\n const timeout = setTimeout(() => {\n settle();\n socket.close();\n reject(new Error(`WebSocket did not open in ${handshakeTimeoutMs} ms`));\n }, handshakeTimeoutMs);\n function settle() {\n clearTimeout(timeout);\n socket.removeEventListener(\"open\", opened);\n socket.removeEventListener(\"close\", failed);\n socket.removeEventListener(\"error\", errored);\n }\n function errored(event: Event) {\n const error = (event as Partial<ErrorEvent>).error;\n if (error instanceof Error) cause = ` (${error.message})`;\n }\n function opened() {\n settle();\n resolve(socket);\n }\n function failed(event: CloseEvent) {\n settle();\n reject(\n new Error(\n `WebSocket connection failed: closed ${event.code}${event.reason ? ` \"${event.reason}\"` : \"\"} before it opened${cause}`,\n ),\n );\n }\n socket.addEventListener(\"open\", opened, { once: true });\n socket.addEventListener(\"error\", errored);\n socket.addEventListener(\"close\", failed, { once: true });\n });\n return (async () => {\n for (let attempt = 1; ; attempt++) {\n try {\n return await once();\n } catch (error) {\n const delay = delays[attempt - 1];\n if (delay === undefined) throw error;\n console.warn({\n event: \"client.platform-failure-socket-open\",\n url: String(url),\n attempt,\n attempts: delays.length + 1,\n retryInMs: delay,\n message: (error as Error).message,\n });\n await sleep(delay);\n }\n }\n })();\n}\n","import { newWebSocketRpcSession, type RpcStub } from \"capnweb\";\nimport type { IterateApi, IterateSessionApi } from \"./api.ts\";\nimport { openSocketWithRetry } from \"./client/socket.ts\";\nimport { OAuthScopes } from \"./oauth-scopes.ts\";\n\n/** What `authenticate` resolves to: the session as a capnweb stub (pipelined; disposable) and the\n * bootstrap info it answered with. Declared, so the package's declarations stay serializable. */\nexport type AuthenticatedApp = {\n api: RpcStub<IterateSessionApi>;\n info: ReturnType<IterateSessionApi[\"info\"]>;\n /** The page names a project this sign-in does not include — its `/projects/<ref>` is absent from\n * `api.projects.list()`: leave for `/.auth/login`, whose page offers to sign in again (the\n * project ticked at consent, or another account) and returns to this very URL. Never settles,\n * like `authenticate`'s own leave for login. */\n signInFor(project: string): Promise<never>;\n};\nexport type IterateClient = { authenticate(next?: string): Promise<AuthenticatedApp> };\n\n/** The browser leaves for the issuer's login (a document navigation) and this never settles — no\n * framework in the loop: a TanStack `beforeLoad` awaiting it ends the way a thrown\n * `redirect({ reloadDocument: true })` did, a plain page simply navigates. */\nfunction leaveForLogin(login: string): Promise<never> {\n window.location.assign(login);\n return new Promise<never>(() => {});\n}\n\n/** One socket to `/api` with the session the OAuth gate resolved from the cookie. The `api` is\n * capnweb's pipelined `authenticate(...)` answer: usable before the socket has even opened, calls\n * queue until it has. */\ntype Connection = { api: RpcStub<IterateSessionApi>; socket: WebSocket; dispose(): void };\nfunction connectionOn(socket: WebSocket): Connection {\n const iterate = newWebSocketRpcSession<IterateApi>(socket);\n const api = iterate.authenticate({\n type: \"from-server-cookie\",\n }) as unknown as RpcStub<IterateSessionApi>;\n return { api, socket, dispose: () => iterate[Symbol.dispose]() };\n}\n\n/** Create once per app — a TanStack route's client-only `beforeLoad`, or a plain page's entry.\n * Every loader and action of the page shares the returned public RPC session.\n *\n * Connecting tries for a while (client/socket.ts: ≈16 s of attempts) before an error reaches the\n * page — a phone waking up or a flapping tunnel is not a reason to show \"connection failed\". And\n * the `api` the page holds is a proxy to the CURRENT connection: when the socket closes, the next\n * call opens a fresh one and pipelines onto it, so a dropped connection costs a reconnect, not the\n * page. A reconnect the platform refuses (the session ended elsewhere) rejects that call; the\n * page's retry runs `authenticate` again — a fresh one, the socket's close forgot the last — whose\n * probe sends the browser to log in. */\nexport function createIterateClient(options: { scopes?: string[] } = {}): IterateClient {\n const scopes = OAuthScopes.parse(options.scopes || []);\n let live: Connection | null = null;\n let connecting: Promise<AuthenticatedApp> | undefined;\n const socketUrl = () => {\n const url = new URL(\"/api\", window.location.href);\n url.protocol = url.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n return url;\n };\n /** Adopt a connection as the live one until its socket closes. A close also forgets the settled\n * `authenticate` — the next one probes `/api` again, so a session that ended elsewhere sends the\n * browser to log in instead of a retry that can only fail. */\n function adopt(socket: WebSocket): Connection {\n const connection = connectionOn(socket);\n live = connection;\n const forget = () => {\n if (live !== connection) return;\n live = null;\n connecting = undefined;\n };\n const dispose = () => {\n forget();\n connection.dispose();\n };\n socket.addEventListener(\n \"close\",\n () => {\n forget();\n window.removeEventListener(\"pagehide\", dispose);\n },\n { once: true },\n );\n window.addEventListener(\"pagehide\", dispose, { once: true });\n return connection;\n }\n // Every property read goes to the live connection — or to a fresh one when the last socket\n // closed (a new WebSocket, no wait: capnweb queues the call until it opens). What comes back is\n // capnweb's own stub for that property — a method stub carries its path, so it is returned as\n // is, never bound or otherwise touched (a capnweb stub answers `.bind` with another stub).\n const api = new Proxy({} as RpcStub<IterateSessionApi>, {\n get(_target, property) {\n const connection = live || adopt(new WebSocket(socketUrl()));\n return Reflect.get(connection.api as object, property);\n },\n });\n const loginUrl = (params: Record<string, string>) =>\n `/.auth/login?${new URLSearchParams({ scope: scopes.join(\" \"), ...params })}`;\n async function connect(next: string): Promise<AuthenticatedApp> {\n const login = loginUrl({ next });\n // HTTP distinguishes 401 from outages; a failed WebSocket is not evidence\n // that the visitor needs to log in. Errors reach the route's error boundary.\n const probe = await fetch(\"/api\", {\n method: \"POST\",\n body: \"\",\n signal: AbortSignal.timeout(10_000),\n });\n await probe.body?.cancel();\n if (probe.status === 401) return leaveForLogin(login);\n if (!probe.ok) throw new Error(`iterate is unavailable (${probe.status}). Please retry.`);\n if (!live) adopt(await openSocketWithRetry(socketUrl()));\n // Consent is task-based: the person may have granted fewer scopes than the app asked for\n // (every scope but `iterate` is optional on the consent page). The granted set is\n // `info.scopes` — an app reads it and offers a step-up link (`/.auth/login?scope=…`) for what\n // it lacks; it is never bounced back to consent for a permission the person declined.\n const info = await api.info();\n return {\n api,\n info,\n signInFor: (project) => leaveForLogin(loginUrl({ next: window.location.href, project })),\n };\n }\n return {\n authenticate(next = \"/\") {\n connecting ||= connect(next).catch((error) => {\n connecting = undefined;\n throw error;\n });\n return connecting;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type PatchOp } from "../lib.ts";
|
|
2
|
+
/** One live-state delta off the wire — the payload of an `events.iterate.com/itx/live-state-changed`
|
|
3
|
+
* ephemeral event, delivered raw to the subscriber. `patch: null` = the change was too large to
|
|
4
|
+
* send — the rev moved, re-read the seed. */
|
|
5
|
+
export type LiveStateDelta = {
|
|
6
|
+
key: string;
|
|
7
|
+
from: number;
|
|
8
|
+
to: number;
|
|
9
|
+
patch: PatchOp[] | null;
|
|
10
|
+
};
|
|
11
|
+
/** What the producer's seed read returns: the current revision paired with the current value. */
|
|
12
|
+
export type LiveStateSeed<S> = {
|
|
13
|
+
rev: number;
|
|
14
|
+
state: S;
|
|
15
|
+
};
|
|
16
|
+
export type LiveStateStore<S> = {
|
|
17
|
+
/** The current value, or undefined until the first seed lands. */
|
|
18
|
+
get(): S | undefined;
|
|
19
|
+
/** The held revision, or null before the first seed. */
|
|
20
|
+
rev(): number | null;
|
|
21
|
+
/** Subscribe to changes (for React's useSyncExternalStore, or a test's await-loop). */
|
|
22
|
+
subscribe(listener: () => void): () => void;
|
|
23
|
+
/** Seed (or re-seed) from a seed read — the first paint, and the heal after a gap. */
|
|
24
|
+
seed(seed: LiveStateSeed<S>): void;
|
|
25
|
+
/** Reduce one delta in; on a revision gap call `resync` and hold the value until a fresh seed. */
|
|
26
|
+
apply(delta: LiveStateDelta, resync: () => void): void;
|
|
27
|
+
};
|
|
28
|
+
export declare function createLiveStateStore<S>(): LiveStateStore<S>;
|
|
29
|
+
/** The slice of an itx session this needs — a capnweb `IterateContextRpcTarget` proxy satisfies it structurally:
|
|
30
|
+
* `subscribe` hands back a DISPOSABLE handle (disposing it removes the subscription server-side). */
|
|
31
|
+
export type LiveStateItx = {
|
|
32
|
+
subscribe(input: {
|
|
33
|
+
name?: string;
|
|
34
|
+
consumes?: string[];
|
|
35
|
+
target: (events: unknown[], range: unknown) => void;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
[Symbol.dispose](): void;
|
|
38
|
+
}>;
|
|
39
|
+
};
|
|
40
|
+
/** A connected live-state subscription: the store rendering it, and the dispose that removes the
|
|
41
|
+
* server-side subscription (the handle's disposer; the session's end does the same). */
|
|
42
|
+
export type LiveStateConnection<S> = {
|
|
43
|
+
store: LiveStateStore<S>;
|
|
44
|
+
/** Unsubscribe on the server and stop reducing deltas. Safe to call more than once. */
|
|
45
|
+
dispose(): Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
/** Subscribe to a producer's live state and reduce it into a store. `readSeed` reads the seed
|
|
48
|
+
* (`itx.invoke("itx.facets.get('slug').liveSnapshot()")` for a processor, or a mini-app's
|
|
49
|
+
* own `state()` method). Subscribe happens BEFORE the first seed, so a delta racing the seed just
|
|
50
|
+
* triggers one seed re-read — never a lost update. Gap heals are SINGLE-FLIGHT (a burst of gapped
|
|
51
|
+
* frames triggers one seed read, not one per frame); a failed heal is reported through `onResync`
|
|
52
|
+
* and retried by the next delivered delta (its `from` still mismatches, so it re-triggers). */
|
|
53
|
+
export declare function connectLiveState<S>(itx: LiveStateItx, opts: {
|
|
54
|
+
key: string;
|
|
55
|
+
name?: string;
|
|
56
|
+
readSeed: () => Promise<LiveStateSeed<S>>;
|
|
57
|
+
/** Called after each gap heal attempt: "healed" on a fresh seed, the error when the seed read
|
|
58
|
+
* failed (the store keeps its last value; the next delta retries). */
|
|
59
|
+
onResync?: (result: "healed" | Error) => void;
|
|
60
|
+
/** Abort while the FIRST seed is still pending (a component unmounting): the row just configured
|
|
61
|
+
* is recalled and the connect rejects — a seed read that never answers leaves nothing lent. */
|
|
62
|
+
signal?: AbortSignal;
|
|
63
|
+
}): Promise<LiveStateConnection<S>>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as oauth from "oauth4webapi";
|
|
2
|
+
/** The platform's issuer as oauth4webapi's authorization server: its endpoints under `/oauth2`, and
|
|
3
|
+
* the `iss` it adds to every authorization response (RFC 9207), which `validateAuthResponse` then
|
|
4
|
+
* requires. The same description for an app's browser session and the CLI. */
|
|
5
|
+
export declare function authorizationServer(issuer: string): oauth.AuthorizationServer;
|
|
6
|
+
/** The same code/PKCE parameters for browser login and a console-minted token. */
|
|
7
|
+
export declare function authorizationCodeRequest(input: {
|
|
8
|
+
issuer: string;
|
|
9
|
+
clientId: string;
|
|
10
|
+
redirectUri: string;
|
|
11
|
+
resources: string[];
|
|
12
|
+
scopes?: string[];
|
|
13
|
+
}): Promise<{
|
|
14
|
+
url: URL;
|
|
15
|
+
state: string;
|
|
16
|
+
verifier: string;
|
|
17
|
+
}>;
|