basketeer 0.1.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.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Config, constants, and small public helpers. GraphQL query strings are
3
+ * internal and live in queries.ts.
4
+ *
5
+ * The api key is public (baked into Tesco's web JS) and rotates roughly monthly
6
+ * — override via the `TESCO_API_KEY` env var or the client `apiKey` option.
7
+ */
8
+ export const PUBLIC_API_KEY = "TvOSZJHlEk0pjniDGQFAc9Q59WGAR4dA";
9
+ export const ENDPOINT = "https://xapi.tesco.com/";
10
+ /**
11
+ * The Tesco order-summary / payment page. Payment is completed HERE in a browser
12
+ * — a separate CSRF-protected checkout app + 3-D Secure card authentication
13
+ * (browser-bound by design, PCI/fraud-sensitive), deliberately NOT part of the
14
+ * pure-HTTP API. Fill the basket + book a slot via this SDK, then finish here.
15
+ */
16
+ export const CHECKOUT_URL = "https://www.tesco.com/checkout/en-GB/groceries/order-summary?basketType=GROCERY";
17
+ /** Micro-frontend tags Tesco's own client sends per operation. Internal. */
18
+ export const MFE = {
19
+ product: "mfe-pdp",
20
+ search: "mfe-plp",
21
+ basket: "mfe-basket",
22
+ slots: "mfe-slots",
23
+ orders: "mfe-orders",
24
+ favourites: "mfe-favourites",
25
+ };
26
+ /** Default contexts for listing upcoming (pending) orders across order types. */
27
+ export const PENDING_ORDER_CONTEXTS = [
28
+ { type: "GROCERY", statuses: ["Pending"] },
29
+ { type: "MARKETPLACE", statuses: ["Pending"] },
30
+ { type: "FNF", statuses: ["Pending"] },
31
+ ];
32
+ /** Fulfilment method types accepted by the slot ops. */
33
+ export const FULFILMENT_TYPE = {
34
+ delivery: "DELIVERY_VAN",
35
+ collection: "COLLECTION_STORE",
36
+ };
37
+ /**
38
+ * Build a category `facet` for {@link Basketeer.browseCategory} from a
39
+ * department name. Tesco encodes it as `"b;" + base64(name)` — e.g.
40
+ * `categoryFacet("Fresh Food")`. Uses the cross-runtime `btoa` (works in Node,
41
+ * Bun, Deno, browsers, workers). You can also lift a `facet` straight from a
42
+ * Tesco category-page URL (`?...&facet=…`).
43
+ */
44
+ export function categoryFacet(department) {
45
+ return `b;${btoa(department)}`;
46
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Raw GraphQL → typed model parsers. Kept separate from the client so they can
3
+ * be unit-tested against captured fixtures, and so the orchestration stays lean.
4
+ *
5
+ * Tesco's GraphQL shape drifts (the public api key rotates ~monthly), so every
6
+ * parser is defensive: it reads untrusted `unknown`, narrows with small
7
+ * accessors, tolerates nulls/missing fields, and never throws on a sparse array.
8
+ */
9
+ import type { Basket, BookedSlot, Order, Product, Promotion, SearchResult, Slot } from "./models.js";
10
+ /** A decoded-JSON object whose fields are still untrusted. */
11
+ export type Raw = Record<string, unknown>;
12
+ export declare function parsePromotions(v: unknown): Promotion[];
13
+ export declare function parseProduct(v: unknown): Product;
14
+ /**
15
+ * Map a ProductInterface node (search / category / favourites) to a SearchResult,
16
+ * or `null` for non-product union members and nodes without a `tpnc` (callers
17
+ * filter these out rather than fabricating an `"undefined"` SKU).
18
+ */
19
+ export declare function parseProductNode(v: unknown): SearchResult | null;
20
+ export declare function parseBasket(v: unknown): Basket;
21
+ export declare function parseSlot(v: unknown): Slot;
22
+ export declare function parseBookedSlot(v: unknown): BookedSlot;
23
+ export declare function parseOrder(v: unknown): Order;
24
+ /** YYYY-MM-DD `offsetDays` from today, in LOCAL time (Tesco slot dates are UK-local). */
25
+ export declare function isoDate(offsetDays?: number): string;
@@ -0,0 +1,167 @@
1
+ import { parseNutrition } from "./nutrition.js";
2
+ const obj = (v) => (v !== null && typeof v === "object" ? v : {});
3
+ const arr = (v) => (Array.isArray(v) ? v : []);
4
+ /** Array of objects, with null/non-object elements dropped. */
5
+ const objs = (v) => arr(v).filter((x) => x !== null && typeof x === "object");
6
+ const str = (v) => (typeof v === "string" ? v : null);
7
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
8
+ const id = (v) => (v == null ? null : String(v));
9
+ function parsePrice(v) {
10
+ const p = obj(v);
11
+ return {
12
+ actual: num(p.actual),
13
+ unitPrice: num(p.unitPrice),
14
+ unitOfMeasure: str(p.unitOfMeasure),
15
+ };
16
+ }
17
+ export function parsePromotions(v) {
18
+ return objs(v).map((p) => {
19
+ const price = obj(p.price);
20
+ return {
21
+ description: str(p.description) ?? "",
22
+ startDate: str(p.startDate),
23
+ endDate: str(p.endDate),
24
+ attributes: arr(p.attributes).filter((a) => typeof a === "string"),
25
+ priceAfterDiscount: num(price.afterDiscount),
26
+ priceBeforeDiscount: num(price.beforeDiscount),
27
+ };
28
+ });
29
+ }
30
+ function parsePackSize(v) {
31
+ const first = Array.isArray(v) ? v[0] : v;
32
+ const p = obj(first);
33
+ const units = str(p.units);
34
+ const value = Number(p.value); // Tesco sends packSize.value as a numeric string
35
+ return units && Number.isFinite(value) ? { value, units } : null;
36
+ }
37
+ export function parseProduct(v) {
38
+ const node = obj(v);
39
+ const details = obj(node.details);
40
+ const nutrition = parseNutrition(arr(details.nutrition));
41
+ return {
42
+ sku: id(node.tpnc) ?? "",
43
+ tpnb: id(node.tpnb),
44
+ title: str(node.title) ?? "",
45
+ brand: str(node.brandName),
46
+ price: parsePrice(node.price),
47
+ packSize: parsePackSize(details.packSize),
48
+ promotions: parsePromotions(node.promotions),
49
+ nutrition,
50
+ macros: nutrition?.macros ?? null,
51
+ raw: v,
52
+ };
53
+ }
54
+ /**
55
+ * Map a ProductInterface node (search / category / favourites) to a SearchResult,
56
+ * or `null` for non-product union members and nodes without a `tpnc` (callers
57
+ * filter these out rather than fabricating an `"undefined"` SKU).
58
+ */
59
+ export function parseProductNode(v) {
60
+ const node = obj(v);
61
+ if (node.tpnc == null)
62
+ return null;
63
+ const seller = obj(arr(obj(node.sellers).results)[0]);
64
+ const promotions = parsePromotions(seller.promotions);
65
+ return {
66
+ sku: String(node.tpnc),
67
+ tpnb: id(node.tpnb),
68
+ title: str(node.title) ?? "",
69
+ brand: str(node.brandName),
70
+ price: parsePrice(seller.price),
71
+ onOffer: promotions.length > 0,
72
+ promotions,
73
+ };
74
+ }
75
+ export function parseBasket(v) {
76
+ const node = obj(v);
77
+ const items = objs(node.items).map((it) => ({
78
+ id: id(it.id) ?? "",
79
+ sku: id(obj(it.product).id),
80
+ quantity: num(it.quantity) ?? 0,
81
+ unit: str(it.unit),
82
+ cost: num(it.cost),
83
+ }));
84
+ return {
85
+ id: id(node.id),
86
+ guidePrice: num(node.guidePrice),
87
+ isInAmend: Boolean(node.isInAmend),
88
+ amendExpiry: str(node.amendExpiry),
89
+ shoppingMethod: str(node.shoppingMethod),
90
+ items,
91
+ raw: v ?? null,
92
+ };
93
+ }
94
+ export function parseSlot(v) {
95
+ const node = obj(v);
96
+ const price = obj(node.price);
97
+ return {
98
+ id: id(node.id) ?? "",
99
+ start: str(node.start) ?? "",
100
+ end: str(node.end) ?? "",
101
+ charge: num(node.charge),
102
+ status: str(node.status) ?? "",
103
+ group: num(node.group),
104
+ priceBeforeDiscount: num(price.beforeDiscount),
105
+ priceAfterDiscount: num(price.afterDiscount),
106
+ locationUuid: id(node.locationUuid),
107
+ };
108
+ }
109
+ export function parseBookedSlot(v) {
110
+ const node = obj(v);
111
+ return {
112
+ id: id(node.id) ?? "",
113
+ status: str(node.status) ?? "",
114
+ start: str(node.start) ?? "",
115
+ end: str(node.end) ?? "",
116
+ reservationExpiry: str(node.reservationExpiry),
117
+ group: num(node.group),
118
+ locationUuid: id(node.locationUuid),
119
+ };
120
+ }
121
+ export function parseOrder(v) {
122
+ const node = obj(v);
123
+ const slot = obj(node.slot);
124
+ const addr = obj(node.address);
125
+ const items = objs(node.items).map((it) => {
126
+ const product = obj(it.product);
127
+ return {
128
+ id: id(it.id) ?? "",
129
+ quantity: num(it.quantity) ?? 0,
130
+ unit: str(it.unit),
131
+ weight: num(it.weight),
132
+ productId: id(product.id),
133
+ title: str(product.title) ?? "",
134
+ };
135
+ });
136
+ return {
137
+ id: id(node.id) ?? "",
138
+ orderNo: id(node.orderNo) ?? "",
139
+ status: str(node.status) ?? "",
140
+ totalPrice: num(node.totalPrice),
141
+ totalItems: num(node.totalItems),
142
+ isInAmend: Boolean(node.isInAmend),
143
+ amendExpiry: str(node.amendExpiryTime),
144
+ shoppingMethod: str(node.shoppingMethod),
145
+ slot: node.slot
146
+ ? { id: id(slot.id), start: str(slot.start), end: str(slot.end), charge: num(slot.charge) }
147
+ : null,
148
+ address: node.address
149
+ ? {
150
+ name: str(addr.name),
151
+ city: str(addr.city),
152
+ addressLine1: str(addr.addressLine1),
153
+ postcode: str(addr.postcode),
154
+ }
155
+ : null,
156
+ items,
157
+ raw: v,
158
+ };
159
+ }
160
+ /** YYYY-MM-DD `offsetDays` from today, in LOCAL time (Tesco slot dates are UK-local). */
161
+ export function isoDate(offsetDays = 0) {
162
+ const d = new Date();
163
+ d.setDate(d.getDate() + offsetDays);
164
+ const m = String(d.getMonth() + 1).padStart(2, "0");
165
+ const day = String(d.getDate()).padStart(2, "0");
166
+ return `${d.getFullYear()}-${m}-${day}`;
167
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * GraphQL operation strings for `xapi.tesco.com`, ported from observed traffic.
3
+ * Internal — not part of the public API. Config/constants live in operations.ts.
4
+ */
5
+ export declare const GET_PRODUCT: string;
6
+ export declare const SEARCH: string;
7
+ export declare const GET_CATEGORY_PRODUCTS: string;
8
+ export declare const GET_FAVOURITES: string;
9
+ export declare const GET_BASKET: string;
10
+ export declare const UPDATE_BASKET: string;
11
+ export declare const GET_UPCOMING_ORDERS: string;
12
+ export declare const AMEND_ORDER: string;
13
+ export declare const CANCEL_ORDER: string;
14
+ export declare const CANCEL_AMEND: string;
15
+ export declare const GET_LAST_FULFILLED_ORDER: string;
16
+ export declare const DELIVERY_SLOTS: string;
17
+ export declare const COLLECTION_SLOTS: string;
18
+ export declare const FULFILMENT: string;
@@ -0,0 +1,235 @@
1
+ /**
2
+ * GraphQL operation strings for `xapi.tesco.com`, ported from observed traffic.
3
+ * Internal — not part of the public API. Config/constants live in operations.ts.
4
+ */
5
+ export const GET_PRODUCT = `
6
+ query GetProduct($tpnc: String!) {
7
+ product(tpnc: $tpnc) {
8
+ tpnb tpnc title brandName
9
+ price { actual unitPrice unitOfMeasure }
10
+ promotions {
11
+ description
12
+ startDate
13
+ endDate
14
+ attributes
15
+ price { afterDiscount beforeDiscount }
16
+ }
17
+ details {
18
+ packSize { value units }
19
+ nutrition { name value1 value2 value3 }
20
+ ingredients
21
+ }
22
+ }
23
+ }`.trim();
24
+ export const SEARCH = `
25
+ query Search($query: String!, $page: Int = 1, $count: Int) {
26
+ search(query: $query, page: $page, count: $count) {
27
+ results {
28
+ node {
29
+ __typename
30
+ ... on ProductInterface {
31
+ tpnc tpnb title brandName
32
+ sellers {
33
+ results {
34
+ price { actual unitPrice unitOfMeasure }
35
+ promotions {
36
+ description
37
+ startDate
38
+ endDate
39
+ attributes
40
+ price { afterDiscount beforeDiscount }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }`.trim();
49
+ export const GET_CATEGORY_PRODUCTS = `
50
+ query GetCategoryProducts($facet: ID, $page: Int = 1, $count: Int) {
51
+ category(facet: $facet, page: $page, count: $count) {
52
+ results {
53
+ node {
54
+ __typename
55
+ ... on ProductInterface {
56
+ tpnc tpnb title brandName
57
+ sellers {
58
+ results {
59
+ price { actual unitPrice unitOfMeasure }
60
+ promotions { description startDate endDate attributes price { afterDiscount beforeDiscount } }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }`.trim();
68
+ export const GET_FAVOURITES = `
69
+ query GetFavourites($page: Int = 1, $count: Int, $sortBy: String) {
70
+ favourites(page: $page, count: $count, sortBy: $sortBy) {
71
+ products {
72
+ __typename
73
+ ... on ProductInterface {
74
+ tpnc tpnb title brandName
75
+ sellers {
76
+ results {
77
+ price { actual unitPrice unitOfMeasure }
78
+ promotions { description startDate endDate attributes price { afterDiscount beforeDiscount } }
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }`.trim();
85
+ export const GET_BASKET = `
86
+ query GetBasket {
87
+ basket {
88
+ id
89
+ guidePrice
90
+ isInAmend
91
+ amendExpiry
92
+ shoppingMethod
93
+ items {
94
+ __typename
95
+ id
96
+ unit
97
+ weight
98
+ cost
99
+ quantity
100
+ product { id __typename }
101
+ }
102
+ }
103
+ }`.trim();
104
+ // Selects the same fields as GET_BASKET so the Basket returned from a write has
105
+ // full fidelity (guidePrice / isInAmend / amendExpiry / unit / weight), not a
106
+ // partial object.
107
+ export const UPDATE_BASKET = `
108
+ mutation UpdateBasket($items: [BasketLineItemInputType], $orderId: ID) {
109
+ basket(items: $items, orderId: $orderId) {
110
+ id
111
+ guidePrice
112
+ isInAmend
113
+ amendExpiry
114
+ shoppingMethod
115
+ items {
116
+ __typename
117
+ id
118
+ unit
119
+ weight
120
+ cost
121
+ quantity
122
+ product { id __typename }
123
+ }
124
+ updates {
125
+ items { id successful __typename }
126
+ }
127
+ }
128
+ }`.trim();
129
+ // ---- orders ----------------------------------------------------------------
130
+ export const GET_UPCOMING_ORDERS = `
131
+ query GetUpcomingOrders($orderContexts: [OrderContextType]) {
132
+ orderSearch(orderContexts: $orderContexts) {
133
+ orders {
134
+ id
135
+ orderNo
136
+ status
137
+ totalPrice
138
+ totalItems
139
+ isInAmend
140
+ amendExpiryTime
141
+ shoppingMethod
142
+ paymentMode
143
+ slot { id start end charge }
144
+ address { name city addressLine1 postcode }
145
+ items { id quantity unit weight product { id title } }
146
+ }
147
+ }
148
+ }`.trim();
149
+ // One unified mutation drives the order lifecycle: action ∈ AMEND | CANCEL | CANCEL_AMEND.
150
+ export const AMEND_ORDER = `
151
+ mutation AmendOrder($orderNo: ID!) {
152
+ order(orderNo: $orderNo, action: AMEND) { id orderNo }
153
+ }`.trim();
154
+ export const CANCEL_ORDER = `
155
+ mutation CancelOrder($orderNo: ID!) {
156
+ order(orderNo: $orderNo, action: CANCEL) { id }
157
+ }`.trim();
158
+ export const CANCEL_AMEND = `
159
+ mutation CancelAmend($orderNo: ID!) {
160
+ order(orderNo: $orderNo, action: CANCEL_AMEND) { id orderNo }
161
+ basket { id isInAmend amendExpiry }
162
+ }`.trim();
163
+ // Last delivered order — the source for "reorder my usual shop".
164
+ export const GET_LAST_FULFILLED_ORDER = `
165
+ query GetLastFulfilledOrder($status: OrderStatusInputType) {
166
+ order(status: $status) {
167
+ orderNo
168
+ items { id quantity unit weight product { id title } }
169
+ }
170
+ }`.trim();
171
+ // ---- slots -----------------------------------------------------------------
172
+ export const DELIVERY_SLOTS = `
173
+ query DeliverySlots($start: String, $end: String, $group: Int, $type: FulfilmentTypeType) {
174
+ delivery(start: $start, end: $end, group: $group) {
175
+ ...Slot
176
+ __typename
177
+ }
178
+ fulfilment(type: $type, range: {start: $start, end: $end}) {
179
+ ...Fulfilment
180
+ __typename
181
+ }
182
+ }
183
+
184
+ fragment Slot on SlotInterface {
185
+ id
186
+ start
187
+ end
188
+ charge
189
+ status
190
+ group
191
+ price {
192
+ beforeDiscount
193
+ afterDiscount
194
+ __typename
195
+ }
196
+ locationUuid
197
+ __typename
198
+ }
199
+
200
+ fragment Fulfilment on FulfilmentType {
201
+ fulfilmentLocation {
202
+ locationUuid
203
+ __typename
204
+ }
205
+ metadata {
206
+ preBookedOrderDays
207
+ __typename
208
+ }
209
+ __typename
210
+ }`.trim();
211
+ export const COLLECTION_SLOTS = `
212
+ query CollectionSlots($start: String, $end: String, $locationUuid: ID) {
213
+ collection(start: $start, end: $end, locationUuid: $locationUuid) {
214
+ id start end charge status group locationUuid
215
+ price { beforeDiscount afterDiscount }
216
+ }
217
+ }`.trim();
218
+ // Reserve (action "BOOK") or release (action "UNBOOK") a slot by its id.
219
+ // `action` mirrors the server's `SlotActions` enum.
220
+ export const FULFILMENT = `
221
+ mutation Fulfilment($slotId: ID, $action: SlotActions) {
222
+ fulfilment(slotId: $slotId, action: $action) {
223
+ slot {
224
+ id
225
+ status
226
+ start
227
+ end
228
+ reservationExpiry
229
+ group
230
+ locationUuid
231
+ __typename
232
+ }
233
+ __typename
234
+ }
235
+ }`.trim();
@@ -0,0 +1,31 @@
1
+ import type { AuthBackend } from "./auth/types.js";
2
+ import type { AuthHeaders } from "./graphql.js";
3
+ import type { Session } from "./models.js";
4
+ import type { TokenStore } from "./store/types.js";
5
+ export interface SessionManagerOptions {
6
+ session?: Session | null;
7
+ backend?: AuthBackend;
8
+ store?: TokenStore;
9
+ }
10
+ /**
11
+ * Owns the auth lifecycle: holds the current {@link Session}, serialises it
12
+ * into request headers, and renews it on demand (proactively or lazily on a
13
+ * 401) via the {@link AuthBackend}, persisting through the {@link TokenStore}.
14
+ */
15
+ export declare class SessionManager {
16
+ private session;
17
+ private readonly backend?;
18
+ private readonly store?;
19
+ constructor(opts?: SessionManagerOptions);
20
+ /** Load any persisted session from the store into the manager. */
21
+ load(): Promise<Session | null>;
22
+ get current(): Session | null;
23
+ setSession(session: Session): Promise<void>;
24
+ /** True when there is no session or the access token is within `skewMs` of expiry. */
25
+ isExpired(skewMs?: number): boolean;
26
+ /** Build the auth headers for a write, or undefined when anonymous. */
27
+ authHeaders(): AuthHeaders | undefined;
28
+ /** Renew via the backend. Returns false if renewal is impossible. */
29
+ refresh(): Promise<boolean>;
30
+ login(...args: Parameters<AuthBackend["login"]>): Promise<Session>;
31
+ }
@@ -0,0 +1,70 @@
1
+ import { jwtExpiryMs } from "./jwt.js";
2
+ /**
3
+ * Owns the auth lifecycle: holds the current {@link Session}, serialises it
4
+ * into request headers, and renews it on demand (proactively or lazily on a
5
+ * 401) via the {@link AuthBackend}, persisting through the {@link TokenStore}.
6
+ */
7
+ export class SessionManager {
8
+ session;
9
+ backend;
10
+ store;
11
+ constructor(opts = {}) {
12
+ this.session = opts.session ?? null;
13
+ this.backend = opts.backend;
14
+ this.store = opts.store;
15
+ }
16
+ /** Load any persisted session from the store into the manager. */
17
+ async load() {
18
+ if (this.store)
19
+ this.session = await this.store.load();
20
+ return this.session;
21
+ }
22
+ get current() {
23
+ return this.session;
24
+ }
25
+ async setSession(session) {
26
+ // Clone so we never mutate the caller's object (it may be shared/frozen).
27
+ const stored = {
28
+ ...session,
29
+ accessTokenExpiry: session.accessTokenExpiry ?? jwtExpiryMs(session.accessToken),
30
+ };
31
+ this.session = stored;
32
+ if (this.store)
33
+ await this.store.save(stored);
34
+ }
35
+ /** True when there is no session or the access token is within `skewMs` of expiry. */
36
+ isExpired(skewMs = 60_000) {
37
+ if (!this.session)
38
+ return true;
39
+ const exp = this.session.accessTokenExpiry;
40
+ return exp !== undefined && Date.now() >= exp - skewMs;
41
+ }
42
+ /** Build the auth headers for a write, or undefined when anonymous. */
43
+ authHeaders() {
44
+ if (!this.session)
45
+ return undefined;
46
+ const cookie = Object.entries(this.session.cookies)
47
+ .map(([k, v]) => `${k}=${v}`)
48
+ .join("; ");
49
+ return {
50
+ authorization: `Bearer ${this.session.accessToken}`,
51
+ customerUuid: this.session.customerUuid,
52
+ cookie: cookie || undefined,
53
+ };
54
+ }
55
+ /** Renew via the backend. Returns false if renewal is impossible. */
56
+ async refresh() {
57
+ if (!this.backend || !this.session)
58
+ return false;
59
+ const renewed = await this.backend.refresh(this.session);
60
+ await this.setSession(renewed);
61
+ return true;
62
+ }
63
+ async login(...args) {
64
+ if (!this.backend)
65
+ throw new Error("No AuthBackend configured — cannot login.");
66
+ const session = await this.backend.login(...args);
67
+ await this.setSession(session);
68
+ return session;
69
+ }
70
+ }
@@ -0,0 +1,11 @@
1
+ import type { Session } from "../models.js";
2
+ import type { TokenStore } from "./types.js";
3
+ /** Persists the session to `~/.basketeer/session.json` (garth-style). */
4
+ export declare class FileTokenStore implements TokenStore {
5
+ private readonly path;
6
+ constructor(path?: string);
7
+ load(): Promise<Session | null>;
8
+ save(session: Session): Promise<void>;
9
+ /** Note: does not securely erase — the file is unlinked, not shredded. */
10
+ clear(): Promise<void>;
11
+ }
@@ -0,0 +1,32 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ const DEFAULT_PATH = join(homedir(), ".basketeer", "session.json");
5
+ /** Persists the session to `~/.basketeer/session.json` (garth-style). */
6
+ export class FileTokenStore {
7
+ path;
8
+ constructor(path = DEFAULT_PATH) {
9
+ this.path = path;
10
+ }
11
+ async load() {
12
+ try {
13
+ const raw = await readFile(this.path, "utf8");
14
+ return JSON.parse(raw);
15
+ }
16
+ catch (err) {
17
+ if (err.code === "ENOENT")
18
+ return null;
19
+ throw err;
20
+ }
21
+ }
22
+ async save(session) {
23
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
24
+ await writeFile(this.path, JSON.stringify(session, null, 2), { mode: 0o600 });
25
+ // writeFile's mode is a no-op if the file already existed with looser perms.
26
+ await chmod(this.path, 0o600);
27
+ }
28
+ /** Note: does not securely erase — the file is unlinked, not shredded. */
29
+ async clear() {
30
+ await rm(this.path, { force: true });
31
+ }
32
+ }
@@ -0,0 +1,10 @@
1
+ import type { Session } from "../models.js";
2
+ import type { TokenStore } from "./types.js";
3
+ /** In-memory store for tests and serverless single-invocation use. */
4
+ export declare class MemoryTokenStore implements TokenStore {
5
+ private session;
6
+ constructor(initial?: Session | null);
7
+ load(): Promise<Session | null>;
8
+ save(session: Session): Promise<void>;
9
+ clear(): Promise<void>;
10
+ }
@@ -0,0 +1,16 @@
1
+ /** In-memory store for tests and serverless single-invocation use. */
2
+ export class MemoryTokenStore {
3
+ session;
4
+ constructor(initial = null) {
5
+ this.session = initial;
6
+ }
7
+ async load() {
8
+ return this.session;
9
+ }
10
+ async save(session) {
11
+ this.session = session;
12
+ }
13
+ async clear() {
14
+ this.session = null;
15
+ }
16
+ }
@@ -0,0 +1,7 @@
1
+ import type { Session } from "../models.js";
2
+ /** Persists the authenticated session between processes. */
3
+ export interface TokenStore {
4
+ load(): Promise<Session | null>;
5
+ save(session: Session): Promise<void>;
6
+ clear(): Promise<void>;
7
+ }
@@ -0,0 +1 @@
1
+ export {};