@aithos/sdk 0.1.0-alpha.1 → 0.1.0-alpha.3

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.
@@ -0,0 +1,116 @@
1
+ /** Default URL of the Aithos auth backend. */
2
+ export declare const DEFAULT_AUTH_BASE_URL = "https://auth.aithos.be";
3
+ /**
4
+ * Construction options for {@link AithosAuth}.
5
+ */
6
+ export interface AithosAuthConfig {
7
+ /**
8
+ * Base URL of the Aithos auth backend. Defaults to
9
+ * {@link DEFAULT_AUTH_BASE_URL}. Override for staging or self-hosted
10
+ * deployments.
11
+ */
12
+ readonly authBaseUrl?: string;
13
+ /**
14
+ * Optional `fetch` implementation. Defaults to `globalThis.fetch`. Used
15
+ * by tests to inject a mock without monkeypatching globals.
16
+ */
17
+ readonly fetch?: typeof fetch;
18
+ /**
19
+ * Optional `window`-like object. Defaults to `globalThis.window` when
20
+ * available. Provided so node-side tests can assert redirect URLs without
21
+ * shimming jsdom.
22
+ */
23
+ readonly window?: Pick<Window, "location" | "history">;
24
+ }
25
+ /**
26
+ * Payload returned by a successful Google sign-in.
27
+ *
28
+ * Wire-compatible with the auth Lambda's `SsoExchangeResponse`. Field names
29
+ * are kept snake_case to match the backend; rationale: avoids an extra
30
+ * mapping layer and keeps the SDK transparent if the user opens the
31
+ * Network panel.
32
+ */
33
+ export interface AithosSession {
34
+ /** HS256 JWT — send in `Authorization: Bearer <session>` to auth/* and
35
+ * app endpoints that consume it. */
36
+ readonly session: string;
37
+ /** JWT expiry, Unix seconds. */
38
+ readonly exp: number;
39
+ /** Aithos DID — `did:aithos:z…`. Stable across all the user's devices. */
40
+ readonly did: string;
41
+ /** User-visible handle (rendered as `@handle`). */
42
+ readonly handle: string;
43
+ /** Encrypted vault, base64. Empty string + version 0 on first sign-in. */
44
+ readonly blob_b64: string;
45
+ /** AES-GCM nonce for the blob, base64 (12 bytes). Empty on first sign-in. */
46
+ readonly blob_nonce_b64: string;
47
+ /** Monotonic blob version. Bumped on every PUT /auth/blob. */
48
+ readonly blob_version: number;
49
+ /** 32-byte vault key, base64. Decrypts {@link blob_b64} via AES-GCM-256. */
50
+ readonly enc_key_b64: string;
51
+ /** True the first time this user signs in. The app should run its
52
+ * onboarding flow rather than mounting an empty blob. */
53
+ readonly is_first_login: boolean;
54
+ }
55
+ /**
56
+ * Options for {@link AithosAuth.signInWithGoogle}.
57
+ */
58
+ export interface SignInWithGoogleOptions {
59
+ /**
60
+ * Opaque deep-link state preserved across the OAuth round-trip and
61
+ * surfaced back to the app via `?app_state=…` on the callback URL. Use
62
+ * to remember "the user clicked sign-in from /settings/billing" so you
63
+ * can restore that route after the redirect chain.
64
+ *
65
+ * Maximum 1024 characters.
66
+ */
67
+ readonly appState?: string;
68
+ }
69
+ /**
70
+ * Authenticator for the Aithos identity service. One instance per app
71
+ * is the recommended pattern (the constructor is cheap; it just trims the
72
+ * URL). All methods are pure — no module-global state.
73
+ */
74
+ export declare class AithosAuth {
75
+ /** Resolved auth base URL with a trailing slash trimmed. */
76
+ readonly authBaseUrl: string;
77
+ private readonly fetchImpl;
78
+ private readonly win;
79
+ constructor(config?: AithosAuthConfig);
80
+ /**
81
+ * Redirect the browser to Google's OAuth consent screen. Must be called
82
+ * synchronously in response to a user gesture (button click) — most
83
+ * browsers block top-level navigation triggered from idle code.
84
+ *
85
+ * Does not return: navigation tears the JS context down. The `never`
86
+ * return type tells callers any code after the call is unreachable.
87
+ */
88
+ signInWithGoogle(opts?: SignInWithGoogleOptions): never;
89
+ /**
90
+ * Inspect the current URL for an `aithos_code` query parameter. If it's
91
+ * present, exchange it at the backend and return the resulting
92
+ * {@link AithosSession}. The query params are stripped from the URL via
93
+ * `history.replaceState` so a page refresh doesn't replay the redeem
94
+ * (which would 410 anyway).
95
+ *
96
+ * Returns `null` when there's no code in the URL — safe to call on every
97
+ * page load. Throws {@link AithosSDKError} on backend errors or when
98
+ * the URL carries `aithos_error=…` (Google denial, token-exchange
99
+ * failure, etc.).
100
+ */
101
+ handleCallback(): Promise<AithosSession | null>;
102
+ /**
103
+ * Programmatically redeem an `aithos_code` for a session. `handleCallback`
104
+ * calls this for you; expose it directly for callers that already pulled
105
+ * the code out of the URL via their own router.
106
+ */
107
+ exchange(aithosCode: string): Promise<AithosSession>;
108
+ /**
109
+ * Stateless sign-out. The Aithos backend doesn't track sessions, so
110
+ * there's nothing to revoke server-side; this method exists so the app
111
+ * has a symmetric API surface and to remind callers to clear their
112
+ * own storage. The Promise always resolves.
113
+ */
114
+ signOut(): Promise<void>;
115
+ }
116
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1,156 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 Mathieu Colla
3
+ // Sign in with Google for Aithos apps.
4
+ //
5
+ // The auth flow is intentionally separate from {@link AithosSDK}: at sign-in
6
+ // time the caller doesn't have a `BrowserIdentity` yet — that's precisely
7
+ // what the flow returns (via the encrypted blob the response carries). So
8
+ // we expose a standalone {@link AithosAuth} class that talks to the Aithos
9
+ // auth backend (`auth.aithos.be`) over plain HTTPS.
10
+ //
11
+ // Flow from the caller's point of view:
12
+ //
13
+ // const auth = new AithosAuth();
14
+ // // on a "Sign in" button:
15
+ // auth.signInWithGoogle({ appState: "/dashboard" }); // navigates away
16
+ //
17
+ // // on the redirect-back page (e.g. /auth/callback):
18
+ // const session = await auth.handleCallback();
19
+ // if (session) {
20
+ // // session.session — JWT, send as Bearer to /auth/blob etc.
21
+ // // session.enc_key_b64 — 32-byte vault key, base64 (raw bytes for
22
+ // // AES-GCM decryption of session.blob_b64)
23
+ // // session.is_first_login — true on the very first sign-in
24
+ // }
25
+ //
26
+ // We deliberately don't persist anything in this module — storage is the
27
+ // app's call (its threat model, its choices around localStorage vs
28
+ // sessionStorage vs IndexedDB). See README "Auth — sessions and storage"
29
+ // for the recommended patterns.
30
+ import { AithosSDKError } from "./types.js";
31
+ /** Default URL of the Aithos auth backend. */
32
+ export const DEFAULT_AUTH_BASE_URL = "https://auth.aithos.be";
33
+ /* -------------------------------------------------------------------------- */
34
+ /* AithosAuth */
35
+ /* -------------------------------------------------------------------------- */
36
+ /**
37
+ * Authenticator for the Aithos identity service. One instance per app
38
+ * is the recommended pattern (the constructor is cheap; it just trims the
39
+ * URL). All methods are pure — no module-global state.
40
+ */
41
+ export class AithosAuth {
42
+ /** Resolved auth base URL with a trailing slash trimmed. */
43
+ authBaseUrl;
44
+ fetchImpl;
45
+ win;
46
+ constructor(config = {}) {
47
+ this.authBaseUrl = trimSlash(config.authBaseUrl ?? DEFAULT_AUTH_BASE_URL);
48
+ this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
49
+ this.win = config.window ?? (typeof window !== "undefined" ? window : undefined);
50
+ }
51
+ /**
52
+ * Redirect the browser to Google's OAuth consent screen. Must be called
53
+ * synchronously in response to a user gesture (button click) — most
54
+ * browsers block top-level navigation triggered from idle code.
55
+ *
56
+ * Does not return: navigation tears the JS context down. The `never`
57
+ * return type tells callers any code after the call is unreachable.
58
+ */
59
+ signInWithGoogle(opts) {
60
+ if (!this.win) {
61
+ throw new AithosSDKError("auth_no_window", "AithosAuth.signInWithGoogle requires a browser window");
62
+ }
63
+ const url = new URL(`${this.authBaseUrl}/auth/sso/google/start`);
64
+ if (opts?.appState) {
65
+ if (opts.appState.length > 1024) {
66
+ throw new AithosSDKError("auth_app_state_too_long", "appState must be ≤ 1024 chars");
67
+ }
68
+ url.searchParams.set("app_state", opts.appState);
69
+ }
70
+ this.win.location.assign(url.toString());
71
+ // Unreachable: location.assign navigates synchronously. The throw is
72
+ // belt-and-braces in case a caller awaits a microtask before unload.
73
+ throw new AithosSDKError("auth_redirecting", "redirecting to google");
74
+ }
75
+ /**
76
+ * Inspect the current URL for an `aithos_code` query parameter. If it's
77
+ * present, exchange it at the backend and return the resulting
78
+ * {@link AithosSession}. The query params are stripped from the URL via
79
+ * `history.replaceState` so a page refresh doesn't replay the redeem
80
+ * (which would 410 anyway).
81
+ *
82
+ * Returns `null` when there's no code in the URL — safe to call on every
83
+ * page load. Throws {@link AithosSDKError} on backend errors or when
84
+ * the URL carries `aithos_error=…` (Google denial, token-exchange
85
+ * failure, etc.).
86
+ */
87
+ async handleCallback() {
88
+ if (!this.win)
89
+ return null;
90
+ const here = new URL(this.win.location.href);
91
+ const error = here.searchParams.get("aithos_error");
92
+ const code = here.searchParams.get("aithos_code");
93
+ const appState = here.searchParams.get("app_state");
94
+ if (error) {
95
+ cleanCallbackParams(this.win, here);
96
+ throw new AithosSDKError(`auth_${error}`, `Sign-in failed: ${error}`, { data: appState ? { app_state: appState } : undefined });
97
+ }
98
+ if (!code)
99
+ return null;
100
+ const session = await this.exchange(code);
101
+ cleanCallbackParams(this.win, here);
102
+ return session;
103
+ }
104
+ /**
105
+ * Programmatically redeem an `aithos_code` for a session. `handleCallback`
106
+ * calls this for you; expose it directly for callers that already pulled
107
+ * the code out of the URL via their own router.
108
+ */
109
+ async exchange(aithosCode) {
110
+ const res = await this.fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
111
+ method: "POST",
112
+ headers: { "content-type": "application/json" },
113
+ body: JSON.stringify({ aithos_code: aithosCode }),
114
+ });
115
+ if (!res.ok) {
116
+ let body;
117
+ try {
118
+ body = (await res.json());
119
+ }
120
+ catch {
121
+ // ignore non-JSON error body
122
+ }
123
+ const code = typeof body?.["code"] === "string" ? `auth_${body["code"]}` : "auth_exchange_failed";
124
+ const message = typeof body?.["error"] === "string"
125
+ ? body["error"]
126
+ : `aithos_code redemption failed (${res.status})`;
127
+ throw new AithosSDKError(code, message, {
128
+ status: res.status,
129
+ ...(body !== undefined ? { data: body } : {}),
130
+ });
131
+ }
132
+ return (await res.json());
133
+ }
134
+ /**
135
+ * Stateless sign-out. The Aithos backend doesn't track sessions, so
136
+ * there's nothing to revoke server-side; this method exists so the app
137
+ * has a symmetric API surface and to remind callers to clear their
138
+ * own storage. The Promise always resolves.
139
+ */
140
+ async signOut() {
141
+ /* no-op; sessions are stateless JWTs. */
142
+ }
143
+ }
144
+ /* -------------------------------------------------------------------------- */
145
+ /* Helpers */
146
+ /* -------------------------------------------------------------------------- */
147
+ function trimSlash(url) {
148
+ return url.endsWith("/") ? url.slice(0, -1) : url;
149
+ }
150
+ function cleanCallbackParams(win, url) {
151
+ url.searchParams.delete("aithos_code");
152
+ url.searchParams.delete("aithos_error");
153
+ url.searchParams.delete("app_state");
154
+ win.history.replaceState(null, "", url.toString());
155
+ }
156
+ //# sourceMappingURL=auth.js.map
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.1.0-alpha.0";
1
+ export declare const VERSION = "0.1.0-alpha.3";
2
2
  export { AithosSDK } from "./sdk.js";
3
3
  export type { AithosSDKConfig } from "./types.js";
4
4
  export { AithosSDKError } from "./types.js";
@@ -6,8 +6,10 @@ export type { AithosSdkEndpoints } from "./endpoints.js";
6
6
  export { DEFAULT_SDK_ENDPOINTS } from "./endpoints.js";
7
7
  export type { ComputeMessage, InvokeBedrockArgs, InvokeBedrockResult, StopReason, } from "./compute.js";
8
8
  export { ComputeNamespace } from "./compute.js";
9
- export type { CreditPackId, CreateTopupSessionArgs, CreateTopupSessionResult, } from "./wallet.js";
9
+ export type { CreditPackId, CreateTopupSessionArgs, CreateTopupSessionResult, GetBalanceArgs, GetBalanceResult, } from "./wallet.js";
10
10
  export { WalletNamespace } from "./wallet.js";
11
+ export { AithosAuth, DEFAULT_AUTH_BASE_URL } from "./auth.js";
12
+ export type { AithosAuthConfig, AithosSession, SignInWithGoogleOptions, } from "./auth.js";
11
13
  export * as ethos from "./ethos.js";
12
14
  export * as onboarding from "./onboarding.js";
13
15
  export * as mandates from "./mandates.js";
package/dist/src/index.js CHANGED
@@ -17,12 +17,16 @@
17
17
  // Public types specific to the SDK (`AithosSDKConfig`, `AithosSDKError`)
18
18
  // are exported from here. Endpoint config (`AithosSdkEndpoints`,
19
19
  // `DEFAULT_SDK_ENDPOINTS`) likewise.
20
- export const VERSION = "0.1.0-alpha.0";
20
+ export const VERSION = "0.1.0-alpha.3";
21
21
  export { AithosSDK } from "./sdk.js";
22
22
  export { AithosSDKError } from "./types.js";
23
23
  export { DEFAULT_SDK_ENDPOINTS } from "./endpoints.js";
24
24
  export { ComputeNamespace } from "./compute.js";
25
25
  export { WalletNamespace } from "./wallet.js";
26
+ // Sign in with Google (and future SSO providers). Lives outside the
27
+ // AithosSDK class because the auth flow runs *before* the user has a
28
+ // BrowserIdentity — that's what the flow returns (via the encrypted blob).
29
+ export { AithosAuth, DEFAULT_AUTH_BASE_URL } from "./auth.js";
26
30
  // Re-exports under stable namespace modules. Apps may also import these
27
31
  // directly via `@aithos/protocol-client`; the SDK simply curates them.
28
32
  export * as ethos from "./ethos.js";
package/dist/src/sdk.js CHANGED
@@ -47,6 +47,8 @@ export class AithosSDK {
47
47
  fetch: fetchImpl,
48
48
  });
49
49
  this.wallet = new WalletNamespace({
50
+ identity: config.identity,
51
+ appDid: config.appDid,
50
52
  userDid: config.identity.did,
51
53
  endpoints: this.endpoints,
52
54
  fetch: fetchImpl,
@@ -1,3 +1,4 @@
1
+ import { type BrowserIdentity } from "@aithos/protocol-client";
1
2
  import { type AithosSdkEndpoints } from "./endpoints.js";
2
3
  /**
3
4
  * Canonical credit-pack identifiers. Pricing and microcredit amounts are
@@ -24,7 +25,30 @@ export interface CreateTopupSessionResult {
24
25
  /** Stripe Checkout session id (for diagnostics). */
25
26
  readonly sessionId: string;
26
27
  }
28
+ export interface GetBalanceArgs {
29
+ /** Abort signal to cancel the request. */
30
+ readonly signal?: AbortSignal;
31
+ }
32
+ export interface GetBalanceResult {
33
+ /** Current wallet balance in microcredits. 0 if the wallet does not yet exist. */
34
+ readonly balance: number;
35
+ /**
36
+ * Microcredits spent today. Resets server-side at midnight UTC.
37
+ * Useful for showing "X / daily cap" UI.
38
+ */
39
+ readonly dailySpent: number;
40
+ /**
41
+ * Whether a wallet row exists for this DID. False on a never-funded
42
+ * identity — `balance` will be 0 in that case.
43
+ */
44
+ readonly exists: boolean;
45
+ }
27
46
  export interface WalletNamespaceDeps {
47
+ /** User identity — needed to sign the balance-lookup envelope. */
48
+ readonly identity: BrowserIdentity;
49
+ /** App DID — sent as audit attribution alongside the balance request. */
50
+ readonly appDid: string;
51
+ /** Pre-resolved DID convenience accessor (mirrors identity.did). */
28
52
  readonly userDid: string;
29
53
  readonly endpoints: AithosSdkEndpoints;
30
54
  readonly fetch: typeof fetch;
@@ -45,5 +69,23 @@ export declare class WalletNamespace {
45
69
  * use the same DID.
46
70
  */
47
71
  createTopupSession(args: CreateTopupSessionArgs): Promise<CreateTopupSessionResult>;
72
+ /**
73
+ * Read the user's current wallet balance.
74
+ *
75
+ * Routed through the compute proxy at `${compute}/v1/invoke` because
76
+ * that's where the Aithos-platform DDB IAM lives — keeps the wallet
77
+ * Lambda focused on Stripe and avoids granting read-on-wallet to a
78
+ * second function. The call is gated by the same signed-envelope
79
+ * verification as `invokeBedrock`: only the owner of the user's
80
+ * `#public` key can read their balance.
81
+ *
82
+ * Returns `{ balance: 0, exists: false, dailySpent: 0 }` for an
83
+ * identity that has never been funded — the call still succeeds,
84
+ * the row just doesn't exist yet.
85
+ *
86
+ * @throws {AithosSDKError} on protocol errors (network, http,
87
+ * envelope-verify failures from the proxy).
88
+ */
89
+ getBalance(args?: GetBalanceArgs): Promise<GetBalanceResult>;
48
90
  }
49
91
  //# sourceMappingURL=wallet.d.ts.map
@@ -1,17 +1,20 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright 2026 Mathieu Colla
3
- // Wallet namespace — Stripe Checkout for credit-pack top-ups.
3
+ // Wallet namespace — credit-pack top-ups (Stripe) and balance lookup.
4
4
  //
5
- // Posts to `${wallet}/v1/wallet/topup/checkout`. The Lambda creates a
6
- // Stripe Checkout session bound to the user's DID via `metadata`, returns
7
- // the hosted URL. The frontend then redirects the user. Stripe webhook
8
- // (server-side) credits the wallet on `checkout.session.completed`.
9
- //
10
- // v0 scope: no envelope verify on the checkout endpoint the link is
11
- // single-use and metadata-bound to `user_did`. Anyone creating a session
12
- // for someone else's DID would just gift them credits, no value to an
13
- // attacker. Envelope-verify gets added in a follow-up if abuse appears.
14
- import { walletTopupCheckoutUrl, } from "./endpoints.js";
5
+ // Two methods:
6
+ // - createTopupSession() posts to `${wallet}/v1/wallet/topup/checkout`
7
+ // to create a Stripe Checkout session. The Lambda binds the session
8
+ // to the user's DID via metadata; the Stripe webhook credits the
9
+ // wallet on `checkout.session.completed`. No envelope verify on this
10
+ // endpoint at v0 (the link is single-use and metadata-boundanyone
11
+ // creating a session for someone else's DID just gifts them credits).
12
+ // - getBalance() JSON-RPC `aithos.wallet_get_balance` against the
13
+ // compute proxy at `${compute}/v1/invoke`. Read-only DDB GetItem on
14
+ // the wallet table, gated by the same signed envelope verification
15
+ // as compute_invoke. Returns the current balance + daily spent.
16
+ import { buildSignedEnvelope, } from "@aithos/protocol-client";
17
+ import { computeInvokeUrl, walletTopupCheckoutUrl, } from "./endpoints.js";
15
18
  import { AithosSDKError } from "./types.js";
16
19
  /**
17
20
  * `sdk.wallet` namespace.
@@ -67,5 +70,67 @@ export class WalletNamespace {
67
70
  }
68
71
  return { checkoutUrl: ok.checkout_url, sessionId: ok.session_id };
69
72
  }
73
+ /**
74
+ * Read the user's current wallet balance.
75
+ *
76
+ * Routed through the compute proxy at `${compute}/v1/invoke` because
77
+ * that's where the Aithos-platform DDB IAM lives — keeps the wallet
78
+ * Lambda focused on Stripe and avoids granting read-on-wallet to a
79
+ * second function. The call is gated by the same signed-envelope
80
+ * verification as `invokeBedrock`: only the owner of the user's
81
+ * `#public` key can read their balance.
82
+ *
83
+ * Returns `{ balance: 0, exists: false, dailySpent: 0 }` for an
84
+ * identity that has never been funded — the call still succeeds,
85
+ * the row just doesn't exist yet.
86
+ *
87
+ * @throws {AithosSDKError} on protocol errors (network, http,
88
+ * envelope-verify failures from the proxy).
89
+ */
90
+ async getBalance(args = {}) {
91
+ const { identity, appDid, endpoints, fetch: fetchImpl } = this.#deps;
92
+ const url = computeInvokeUrl(endpoints);
93
+ const params = { app_did: appDid };
94
+ const envelope = buildSignedEnvelope({
95
+ iss: identity.did,
96
+ aud: url,
97
+ method: "aithos.wallet_get_balance",
98
+ verificationMethod: `${identity.did}#public`,
99
+ params,
100
+ signer: identity.public,
101
+ });
102
+ let res;
103
+ try {
104
+ res = await fetchImpl(url, {
105
+ method: "POST",
106
+ headers: { "content-type": "application/json" },
107
+ body: JSON.stringify({
108
+ jsonrpc: "2.0",
109
+ id: "aithos.wallet_get_balance",
110
+ method: "aithos.wallet_get_balance",
111
+ params: { ...params, _envelope: envelope },
112
+ }),
113
+ ...(args.signal ? { signal: args.signal } : {}),
114
+ });
115
+ }
116
+ catch (e) {
117
+ throw new AithosSDKError("network", e.message);
118
+ }
119
+ if (!res.ok) {
120
+ throw new AithosSDKError("http", `HTTP ${res.status} ${res.statusText}`, { status: res.status });
121
+ }
122
+ const body = (await res.json());
123
+ if (body.error) {
124
+ throw new AithosSDKError(String(body.error.code), body.error.message, body.error.data ? { data: body.error.data } : undefined);
125
+ }
126
+ if (!body.result) {
127
+ throw new AithosSDKError("empty", "empty result from wallet_get_balance");
128
+ }
129
+ return {
130
+ balance: typeof body.result.balance === "number" ? body.result.balance : 0,
131
+ dailySpent: typeof body.result.dailySpent === "number" ? body.result.dailySpent : 0,
132
+ exists: body.result.exists === true,
133
+ };
134
+ }
70
135
  }
71
136
  //# sourceMappingURL=wallet.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=auth.test.d.ts.map
@@ -0,0 +1,175 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 Mathieu Colla
3
+ // Unit tests for AithosAuth — Sign in with Google flow.
4
+ import { strict as assert } from "node:assert";
5
+ import { describe, it } from "node:test";
6
+ import { AithosAuth, AithosSDKError } from "../src/index.js";
7
+ /** Tiny window-shim that records calls instead of actually navigating. */
8
+ function makeFakeWindow(initialHref) {
9
+ let href = initialHref;
10
+ let assigned = null;
11
+ let replacedHref = null;
12
+ const win = {
13
+ location: {
14
+ get href() {
15
+ return href;
16
+ },
17
+ assign(target) {
18
+ assigned = target;
19
+ },
20
+ },
21
+ history: {
22
+ replaceState(_state, _title, url) {
23
+ replacedHref = url;
24
+ href = url;
25
+ },
26
+ },
27
+ };
28
+ return {
29
+ win: win,
30
+ get assigned() {
31
+ return assigned;
32
+ },
33
+ get replacedHref() {
34
+ return replacedHref;
35
+ },
36
+ };
37
+ }
38
+ function fakeSession(overrides = {}) {
39
+ return {
40
+ session: "jwt-token-here",
41
+ exp: Math.floor(Date.now() / 1000) + 3600,
42
+ did: "did:aithos:zABC123",
43
+ handle: "alice-x9y2",
44
+ blob_b64: "",
45
+ blob_nonce_b64: "",
46
+ blob_version: 0,
47
+ enc_key_b64: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=",
48
+ is_first_login: true,
49
+ ...overrides,
50
+ };
51
+ }
52
+ /* -------------------------------------------------------------------------- */
53
+ /* signInWithGoogle */
54
+ /* -------------------------------------------------------------------------- */
55
+ describe("AithosAuth.signInWithGoogle", () => {
56
+ it("navigates to /auth/sso/google/start with no params by default", () => {
57
+ const w = makeFakeWindow("https://app.aithos.be/login");
58
+ const auth = new AithosAuth({
59
+ authBaseUrl: "https://auth.example.test",
60
+ window: w.win,
61
+ });
62
+ assert.throws(() => auth.signInWithGoogle(), AithosSDKError);
63
+ assert.equal(w.assigned, "https://auth.example.test/auth/sso/google/start");
64
+ });
65
+ it("forwards appState as the app_state query param", () => {
66
+ const w = makeFakeWindow("https://app.aithos.be/login");
67
+ const auth = new AithosAuth({
68
+ authBaseUrl: "https://auth.example.test",
69
+ window: w.win,
70
+ });
71
+ assert.throws(() => auth.signInWithGoogle({ appState: "/dashboard" }), AithosSDKError);
72
+ const url = new URL(w.assigned);
73
+ assert.equal(url.searchParams.get("app_state"), "/dashboard");
74
+ });
75
+ it("rejects appState longer than 1024 chars without navigating", () => {
76
+ const w = makeFakeWindow("https://app.aithos.be/login");
77
+ const auth = new AithosAuth({
78
+ authBaseUrl: "https://auth.example.test",
79
+ window: w.win,
80
+ });
81
+ const tooLong = "x".repeat(1025);
82
+ assert.throws(() => auth.signInWithGoogle({ appState: tooLong }), (e) => e instanceof AithosSDKError && e.code === "auth_app_state_too_long");
83
+ assert.equal(w.assigned, null, "must not have navigated");
84
+ });
85
+ it("trims a trailing slash from authBaseUrl", () => {
86
+ const w = makeFakeWindow("https://app.aithos.be/");
87
+ const auth = new AithosAuth({
88
+ authBaseUrl: "https://auth.example.test/",
89
+ window: w.win,
90
+ });
91
+ assert.throws(() => auth.signInWithGoogle(), AithosSDKError);
92
+ assert.equal(w.assigned, "https://auth.example.test/auth/sso/google/start");
93
+ });
94
+ });
95
+ /* -------------------------------------------------------------------------- */
96
+ /* handleCallback */
97
+ /* -------------------------------------------------------------------------- */
98
+ describe("AithosAuth.handleCallback", () => {
99
+ it("returns null when the URL has no aithos_code", async () => {
100
+ const w = makeFakeWindow("https://app.aithos.be/auth/callback");
101
+ const auth = new AithosAuth({ window: w.win, fetch: undefinedFetch() });
102
+ const session = await auth.handleCallback();
103
+ assert.equal(session, null);
104
+ });
105
+ it("exchanges the code, returns the session, and strips query params", async () => {
106
+ const w = makeFakeWindow("https://app.aithos.be/auth/callback?aithos_code=abc123XYZ_-456&app_state=/dashboard");
107
+ const session = fakeSession({ is_first_login: true });
108
+ let capturedBody;
109
+ const fakeFetch = async (input, init) => {
110
+ assert.equal(typeof input === "string" ? input : input.toString(), "https://auth.example.test/auth/sso/exchange");
111
+ capturedBody = JSON.parse(init?.body);
112
+ return new Response(JSON.stringify(session), {
113
+ status: 200,
114
+ headers: { "content-type": "application/json" },
115
+ });
116
+ };
117
+ const auth = new AithosAuth({
118
+ authBaseUrl: "https://auth.example.test",
119
+ window: w.win,
120
+ fetch: fakeFetch,
121
+ });
122
+ const out = await auth.handleCallback();
123
+ assert.deepEqual(out, session);
124
+ assert.equal(capturedBody?.aithos_code, "abc123XYZ_-456");
125
+ assert.equal(w.replacedHref, "https://app.aithos.be/auth/callback", "callback params must be stripped from the URL");
126
+ });
127
+ it("throws AithosSDKError with the backend code on aithos_error", async () => {
128
+ const w = makeFakeWindow("https://app.aithos.be/auth/callback?aithos_error=google_id_token&app_state=/dashboard");
129
+ const auth = new AithosAuth({
130
+ authBaseUrl: "https://auth.example.test",
131
+ window: w.win,
132
+ fetch: undefinedFetch(),
133
+ });
134
+ await assert.rejects(auth.handleCallback(), (e) => e instanceof AithosSDKError && e.code === "auth_google_id_token");
135
+ // URL is cleaned even on error so a refresh doesn't loop the message.
136
+ assert.equal(w.replacedHref, "https://app.aithos.be/auth/callback");
137
+ });
138
+ it("wraps a 410 'code_consumed' as AithosSDKError(code='auth_code_consumed')", async () => {
139
+ const w = makeFakeWindow("https://app.aithos.be/auth/callback?aithos_code=abc123XYZ_-456");
140
+ const fakeFetch = async () => new Response(JSON.stringify({ error: "aithos_code expired or already used", code: "code_consumed" }), { status: 410, headers: { "content-type": "application/json" } });
141
+ const auth = new AithosAuth({
142
+ authBaseUrl: "https://auth.example.test",
143
+ window: w.win,
144
+ fetch: fakeFetch,
145
+ });
146
+ await assert.rejects(auth.handleCallback(), (e) => e instanceof AithosSDKError &&
147
+ e.code === "auth_code_consumed" &&
148
+ e.status === 410);
149
+ });
150
+ it("returns null in non-browser environments (no window)", async () => {
151
+ // No `window` injected and `globalThis.window` is undefined under Node test.
152
+ const auth = new AithosAuth({ window: undefined, fetch: undefinedFetch() });
153
+ const session = await auth.handleCallback();
154
+ assert.equal(session, null);
155
+ });
156
+ });
157
+ /* -------------------------------------------------------------------------- */
158
+ /* signOut */
159
+ /* -------------------------------------------------------------------------- */
160
+ describe("AithosAuth.signOut", () => {
161
+ it("resolves immediately (sessions are stateless)", async () => {
162
+ const auth = new AithosAuth({ window: undefined, fetch: undefinedFetch() });
163
+ await auth.signOut();
164
+ });
165
+ });
166
+ /* -------------------------------------------------------------------------- */
167
+ /* Helpers */
168
+ /* -------------------------------------------------------------------------- */
169
+ /** A fetch that fails the test if invoked — for code paths that mustn't fetch. */
170
+ function undefinedFetch() {
171
+ return async () => {
172
+ throw new Error("fetch should not have been called");
173
+ };
174
+ }
175
+ //# sourceMappingURL=auth.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aithos/sdk",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.3",
4
4
  "description": "Aithos SDK — high-level TypeScript developer kit for building agentic apps on the Aithos protocol. Wraps @aithos/protocol-client and exposes the Aithos compute proxy and wallet (Stripe top-up) endpoints.",
5
5
  "keywords": [
6
6
  "aithos",