runline 0.30.0 → 0.31.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.
Files changed (35) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/plugins/_shared/authedFetch.js +33 -0
  4. package/dist/plugins/_shared/provider.js +80 -0
  5. package/dist/plugins/_shared/tokenRefresh.js +5 -3
  6. package/dist/plugins/gett/src/index.js +358 -0
  7. package/dist/plugins/gett/src/places.js +140 -0
  8. package/dist/plugins/gett/src/rides.js +144 -0
  9. package/dist/plugins/gett/src/session.js +242 -0
  10. package/dist/plugins/gett/src/shared.js +180 -0
  11. package/dist/plugins/linear/src/attachments.js +2 -2
  12. package/dist/plugins/linear/src/cycles.js +6 -6
  13. package/dist/plugins/linear/src/index.js +2 -2
  14. package/dist/plugins/linear/src/initiatives.js +6 -6
  15. package/dist/plugins/linear/src/issues.js +9 -9
  16. package/dist/plugins/linear/src/labels.js +6 -6
  17. package/dist/plugins/linear/src/organization.js +2 -2
  18. package/dist/plugins/linear/src/projects.js +12 -12
  19. package/dist/plugins/linear/src/shared.js +148 -20
  20. package/dist/plugins/linear/src/states.js +3 -3
  21. package/dist/plugins/linear/src/teams.js +6 -5
  22. package/dist/plugins/linear/src/users.js +3 -3
  23. package/dist/plugins/linear/src/views.js +10 -8
  24. package/dist/plugins/linear/src/webhooks.js +5 -5
  25. package/dist/plugins/openai/src/index.js +28 -9
  26. package/dist/plugins/typesafe/src/guide.js +172 -0
  27. package/dist/plugins/typesafe/src/index.js +169 -0
  28. package/dist/plugins/typesafe/src/questions.js +180 -0
  29. package/dist/plugins/typesafe/src/shared.js +166 -0
  30. package/dist/plugins/wolt/src/account.js +427 -0
  31. package/dist/plugins/wolt/src/catalog.js +166 -0
  32. package/dist/plugins/wolt/src/index.js +526 -0
  33. package/dist/plugins/wolt/src/orders.js +349 -0
  34. package/dist/plugins/wolt/src/shared.js +218 -0
  35. package/package.json +1 -1
@@ -0,0 +1,349 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { selectAddress } from "./account.js";
3
+ import { assortment, itemsById, optionDefinitions, } from "./catalog.js";
4
+ import { arr, authed, CONSUMER, cfgOf, http, numOrNull, obj, pick, RESTAURANT, seg, WoltError, } from "./shared.js";
5
+ export async function resolveCart(slug, cart) {
6
+ const a = await assortment(slug);
7
+ const items = itemsById(a);
8
+ const definitions = optionDefinitions(a);
9
+ const categoryOfItem = new Map();
10
+ for (const c of arr(a.categories)) {
11
+ const category = obj(c);
12
+ const categoryId = pick(category.id);
13
+ if (!categoryId)
14
+ continue;
15
+ for (const id of arr(category.item_ids)) {
16
+ const itemId = pick(id);
17
+ if (itemId && !categoryOfItem.has(itemId))
18
+ categoryOfItem.set(itemId, categoryId);
19
+ }
20
+ }
21
+ const menu_items = [];
22
+ const purchase_items = [];
23
+ const lines = [];
24
+ for (const line of cart) {
25
+ const item = items.get(line.id);
26
+ if (!item)
27
+ throw new Error(`wolt: item ${line.id} is not on venue ${slug}`);
28
+ const count = line.count ?? 1;
29
+ const categoryId = categoryOfItem.get(line.id) ?? null;
30
+ const basePrice = numOrNull(item.price) ?? 0;
31
+ let optionsTotal = 0;
32
+ const checkoutOptions = [];
33
+ const purchaseOptions = [];
34
+ for (const group of line.options ?? []) {
35
+ const onItem = arr(item.options)
36
+ .map(obj)
37
+ .find((o) => pick(o.id) === group.id);
38
+ if (!onItem)
39
+ throw new Error(`wolt: option group ${group.id} is not on item ${line.id}`);
40
+ const definition = obj(definitions.get(pick(onItem.option_id) ?? ""));
41
+ const values = group.values.map((v) => {
42
+ const declared = arr(definition.values)
43
+ .map(obj)
44
+ .find((d) => pick(d.id) === v.id);
45
+ if (!declared)
46
+ throw new Error(`wolt: option ${v.id} is not a choice in group ${group.id}`);
47
+ const price = numOrNull(declared.price) ?? 0;
48
+ const valueCount = v.count ?? 1;
49
+ optionsTotal += price * valueCount;
50
+ return { id: v.id, price, count: valueCount };
51
+ });
52
+ checkoutOptions.push({ id: group.id, values });
53
+ purchaseOptions.push({
54
+ id: { $oid: group.id },
55
+ type: pick(definition.type) ?? "Multichoice",
56
+ values: values.map((v) => ({
57
+ id: { $oid: v.id },
58
+ price: v.price,
59
+ count: v.count,
60
+ })),
61
+ });
62
+ }
63
+ const amount = (basePrice + optionsTotal) * count;
64
+ menu_items.push({
65
+ id: line.id,
66
+ count,
67
+ options: checkoutOptions,
68
+ base_price: basePrice,
69
+ end_amount: amount,
70
+ category_id: categoryId,
71
+ category_ids: categoryId ? [categoryId] : [],
72
+ exclude_from_discounts: false,
73
+ exclude_from_discounts_min_basket: false,
74
+ exclude_from_credits: false,
75
+ alcohol_permille: numOrNull(item.alcohol_permille) ?? 0,
76
+ restrictions: [],
77
+ });
78
+ purchase_items.push({
79
+ id: { $oid: line.id },
80
+ baseprice: basePrice,
81
+ options: purchaseOptions,
82
+ count,
83
+ end_amount: amount,
84
+ alcohol_percentage: 0,
85
+ checksum: pick(item.checksum),
86
+ substitution_settings: { is_allowed: true },
87
+ from_recommendation: false,
88
+ });
89
+ lines.push({ id: line.id, name: pick(item.name), count, amount });
90
+ }
91
+ return { menu_items, purchase_items, lines };
92
+ }
93
+ export async function estimate(venue, cart, method, tip) {
94
+ const menuItems = cart.map((line) => ({
95
+ id: line.id,
96
+ count: line.count ?? 1,
97
+ options: line.options ?? [],
98
+ }));
99
+ const r = await http(CONSUMER, "/order-xp/web/v1/pages/venue/pricing-estimates", {
100
+ method: "POST",
101
+ body: {
102
+ purchase_plan: {
103
+ venue: {
104
+ id: venue.id,
105
+ country: venue.country,
106
+ currency: venue.currency,
107
+ },
108
+ delivery_method: method,
109
+ menu_items: menuItems,
110
+ courier_tip: tip,
111
+ use_promo_discount_ids: [],
112
+ },
113
+ },
114
+ });
115
+ return {
116
+ venue: { id: venue.id, slug: venue.slug, name: venue.name },
117
+ currency: venue.currency,
118
+ items: menuItems,
119
+ estimate: r,
120
+ };
121
+ }
122
+ async function checkout(ctx, venue, address, menuItems, tip) {
123
+ const cfg = cfgOf(ctx);
124
+ const [longitude, latitude] = address.coordinates ?? [null, null];
125
+ return authed(ctx, CONSUMER, "/order-xp/mobile/v2/pages/checkout", {
126
+ method: "POST",
127
+ body: {
128
+ purchase_plan: {
129
+ delivery: {
130
+ delivery_coordinates: { latitude, longitude },
131
+ delivery_info_id: address.id,
132
+ },
133
+ venue: {
134
+ id: venue.id,
135
+ country: venue.country,
136
+ currency: venue.currency,
137
+ },
138
+ delivery_method: "homedelivery",
139
+ menu_items: menuItems,
140
+ use_cash: false,
141
+ selected_offer_ids: [],
142
+ pre_considered_discount_ids: [],
143
+ courier_tip: tip,
144
+ use_loyalty_points_amount: 0,
145
+ use_credits_and_tokens: true,
146
+ did_client_disable_purchase: false,
147
+ payment_methods: cfg.paymentMethodId
148
+ ? [{ id: cfg.paymentMethodId, type: "card" }]
149
+ : [],
150
+ delivery_config: { method: "homedelivery", schedule: "standard" },
151
+ is_partial_address: false,
152
+ },
153
+ },
154
+ });
155
+ }
156
+ function deliveryEstimate(checkoutBody, venue) {
157
+ const configs = arr(checkoutBody.delivery_configs).map(obj);
158
+ const chosen = configs.find((c) => pick(c.method) === "homedelivery" &&
159
+ pick(c.schedule) === "standard" &&
160
+ numOrNull(obj(c.estimate).max) !== null) ?? configs.find((c) => numOrNull(obj(c.estimate).max) !== null);
161
+ const range = obj(chosen?.estimate);
162
+ const min = numOrNull(range.min);
163
+ const max = numOrNull(range.max);
164
+ if (min !== null && max !== null)
165
+ return `${min}-${max}`;
166
+ const declared = obj(venue.estimate_range);
167
+ const dmin = numOrNull(declared.min);
168
+ const dmax = numOrNull(declared.max);
169
+ if (dmin !== null && dmax !== null)
170
+ return `${dmin}-${dmax}`;
171
+ const single = venue.live_estimate_minutes ?? venue.estimate_minutes;
172
+ return single !== null ? `${single}-${single + 10}` : "30-45";
173
+ }
174
+ export async function planOrder(ctx, venue, addresses, wantedAddress, slug, cart, tip) {
175
+ const address = selectAddress(addresses, wantedAddress);
176
+ const resolved = await resolveCart(slug, cart);
177
+ const body = await checkout(ctx, venue, address, resolved.menu_items, tip);
178
+ if (body.purchasing_disabled === true) {
179
+ const reason = pick(obj(body.call_to_action).text) ??
180
+ "Wolt has disabled purchasing here";
181
+ throw new Error(`wolt: this order cannot be placed — ${reason}`);
182
+ }
183
+ const validation = obj(body.purchase_validation);
184
+ const payable = numOrNull(validation.end_amount) ?? numOrNull(body.payable_amount);
185
+ // Without a total there is nothing to show a person and nothing to bind a
186
+ // confirmation to, and the purchase would post a null amount.
187
+ if (payable === null)
188
+ throw new Error("wolt: the checkout returned no payable amount, so this order cannot be priced");
189
+ return {
190
+ venue,
191
+ address,
192
+ cart: resolved,
193
+ checkout: body,
194
+ payable_amount: payable,
195
+ delivery_price: numOrNull(validation.delivery_price),
196
+ estimate_minutes: deliveryEstimate(body, venue),
197
+ tip,
198
+ };
199
+ }
200
+ /**
201
+ * A fingerprint of everything a person agrees to when they say yes: the venue,
202
+ * every line and its amount, where it is going, and the total. Re-derived on the
203
+ * confirming call, so a total that moved in between cannot be charged against
204
+ * the old consent.
205
+ */
206
+ export function quoteFor(plan) {
207
+ const material = JSON.stringify([
208
+ plan.venue.id ?? plan.venue.slug ?? "",
209
+ plan.address.id ?? "",
210
+ plan.cart.lines.map((l) => [l.id, l.count, l.amount]),
211
+ plan.payable_amount,
212
+ plan.venue.currency ?? "",
213
+ plan.tip,
214
+ ]);
215
+ return `q_${createHash("sha256").update(material).digest("hex").slice(0, 16)}`;
216
+ }
217
+ export function previewOf(plan) {
218
+ return {
219
+ venue: {
220
+ id: plan.venue.id,
221
+ slug: plan.venue.slug,
222
+ name: plan.venue.name,
223
+ },
224
+ deliver_to: {
225
+ id: plan.address.id,
226
+ address: plan.address.address,
227
+ city: plan.address.city,
228
+ },
229
+ items: plan.cart.lines,
230
+ delivery_price: plan.delivery_price,
231
+ tip: plan.tip,
232
+ payable_amount: plan.payable_amount,
233
+ currency: plan.venue.currency,
234
+ estimate_minutes: plan.estimate_minutes,
235
+ };
236
+ }
237
+ export async function purchase(ctx, plan) {
238
+ const cfg = cfgOf(ctx);
239
+ if (!cfg.paymentMethodId)
240
+ throw new Error("wolt: no saved card (paymentMethodId) — a saved card is required, Google Pay cannot be replayed headless");
241
+ const validation = obj(plan.checkout.purchase_validation);
242
+ const [longitude, latitude] = plan.address.coordinates ?? [null, null];
243
+ const r = await authed(ctx, RESTAURANT, "/v2/purchases", {
244
+ method: "POST",
245
+ body: {
246
+ venue_id: plan.venue.id,
247
+ checkout_id: plan.checkout.id,
248
+ payment_method_id: cfg.paymentMethodId,
249
+ payment_method_type: "card",
250
+ end_amount: plan.payable_amount,
251
+ end_amount_rounding: numOrNull(validation.end_amount_rounding) ?? 0,
252
+ items: plan.cart.purchase_items,
253
+ client_pre_estimate: plan.estimate_minutes,
254
+ signature_datetime: { $date: Date.now() },
255
+ client_nonce: randomUUID(),
256
+ delivery_method: "homedelivery",
257
+ currency: plan.venue.currency,
258
+ preorder: false,
259
+ no_credits_or_tokens: false,
260
+ credits_amount: numOrNull(validation.credits_amount) ?? 0,
261
+ delivery_price: plan.delivery_price ?? 0,
262
+ delivery_info: {
263
+ id: { $oid: plan.address.id },
264
+ delivery_comments: "\n",
265
+ location: {
266
+ address: plan.address.address,
267
+ city: plan.address.city,
268
+ coordinates: { coordinates: [longitude, latitude], type: "Point" },
269
+ },
270
+ use_last_100m_address_picker: true,
271
+ },
272
+ tip_amount: plan.tip,
273
+ language: "en",
274
+ additional_checkout_options: { no_contact_delivery: false },
275
+ ravelin_device_id: cfg.ravelinDeviceId,
276
+ browser_info: {
277
+ color_depth: 32,
278
+ screen_width: 1080,
279
+ screen_height: 2400,
280
+ time_zone_offset: -180,
281
+ java_enabled: false,
282
+ language: "en",
283
+ ravelin_device_id: cfg.ravelinDeviceId,
284
+ },
285
+ to: { id: { $oid: plan.venue.id }, type: "venue" },
286
+ signature: "N/A",
287
+ type: "purchase",
288
+ device_channel: "app",
289
+ pricing_model_version: 2023,
290
+ use_self_service_cancellation: false,
291
+ discounts: arr(validation.discounts),
292
+ surcharges: arr(validation.surcharges),
293
+ offers: arr(validation.offers),
294
+ menu_items_source: "consumer-assortment",
295
+ },
296
+ });
297
+ const first = obj(Array.isArray(r.results) ? r.results[0] : r.results);
298
+ const orderId = pick(obj(first.id).$oid, first.id, obj(r.id).$oid, r.id, r.purchase_id);
299
+ return { order_id: orderId };
300
+ }
301
+ export async function orderStatus(ctx, orderId) {
302
+ const id = seg(orderId, "order_id");
303
+ const [tracking, page] = await Promise.all([
304
+ authed(ctx, RESTAURANT, `/v2/order_details/purchase_tracking/${id}`),
305
+ authed(ctx, CONSUMER, `/order-xp/v1/pages/order-tracking/${id}`).catch(() => null),
306
+ ]);
307
+ const details = obj(tracking.order_details);
308
+ const context = obj(obj(page ?? {}).purchase_context);
309
+ const eta = numOrNull(obj(details.delivery_eta).$date);
310
+ return {
311
+ order_id: orderId,
312
+ status: pick(context.purchase_status),
313
+ venue: pick(context.venue_name),
314
+ eta: eta !== null ? new Date(eta).toISOString() : null,
315
+ delivery_distance: numOrNull(details.delivery_distance),
316
+ handshake_code: pick(details.delivery_handshake_code),
317
+ couriers: arr(tracking.drivers).map((d) => ({
318
+ name: pick(obj(d).name),
319
+ coordinates: obj(d).coordinates ?? obj(d).location ?? null,
320
+ })),
321
+ currency: pick(details.currency),
322
+ poll_after_seconds: numOrNull(tracking.expires_in_seconds),
323
+ };
324
+ }
325
+ export async function cancelOrder(ctx, orderId, reason) {
326
+ const id = seg(orderId, "order_id");
327
+ try {
328
+ await authed(ctx, RESTAURANT, `/v2/purchases/${id}/cancel`, {
329
+ method: "PUT",
330
+ body: { cancellation_reason: reason },
331
+ });
332
+ return { cancelled: true, order_id: orderId, reason };
333
+ }
334
+ catch (e) {
335
+ // A refusal is the normal outcome once the venue has accepted, so it comes
336
+ // back as an answer rather than an exception. error_code 4051 is that case.
337
+ if (!(e instanceof WoltError))
338
+ throw e;
339
+ const code = numOrNull(e.details().error_code);
340
+ return {
341
+ cancelled: false,
342
+ order_id: orderId,
343
+ error_code: code,
344
+ note: code === 4051
345
+ ? "The venue already accepted this order, so self-service cancellation is closed. Wolt support can still help."
346
+ : "Wolt refused the cancellation.",
347
+ };
348
+ }
349
+ }
@@ -0,0 +1,218 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { authedFetch } from "../../_shared/authedFetch.js";
3
+ import { arr, num, numOrNull, obj, pick, readBounded, seg as segment, } from "../../_shared/provider.js";
4
+ import { coordinatedAccessToken } from "../../_shared/tokenRefresh.js";
5
+ export { arr, num, numOrNull, obj, pick };
6
+ /**
7
+ * Transport and credentials for the Wolt consumer surface.
8
+ *
9
+ * Three hosts, two client personas. Catalogue reads are anonymous and go out as
10
+ * the web app; everything authenticated goes out as the mobile app, because the
11
+ * API only accepts the authed calls from a client that looks like the phone.
12
+ */
13
+ export const RESTAURANT = "restaurant-api.wolt.com";
14
+ export const CONSUMER = "consumer-api.wolt.com";
15
+ export const AUTH = "authentication.wolt.com";
16
+ export const DEF_LAT = 32.0853;
17
+ export const DEF_LON = 34.7818; // TLV
18
+ export const AUDIENCE = "restaurant-api";
19
+ export const CAPABILITIES = "access_confirmation";
20
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
21
+ const DEFAULT_TOKEN_TTL_MS = 3_600_000;
22
+ const WEB_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
23
+ /** Mobile client headers; the authed endpoints reject anything that is not the app. */
24
+ const MOBILE = {
25
+ "app-language": "en",
26
+ "app-locale": "en-US",
27
+ "client-version": "26.30.4",
28
+ clientversionnumber: "142026304",
29
+ platform: "Android",
30
+ "user-agent": "Wolt/26.30.4; Build/142026304; Android/16; Google sdk_gphone",
31
+ };
32
+ const WEB = {
33
+ "user-agent": WEB_UA,
34
+ "accept-language": "en",
35
+ "app-language": "en",
36
+ platform: "Web",
37
+ "client-version": "1.16.125",
38
+ origin: "https://wolt.com",
39
+ referer: "https://wolt.com/",
40
+ };
41
+ export const cfgOf = (ctx) => (ctx.connection.config || {});
42
+ export const seg = (value, what) => segment(value, what, "wolt");
43
+ /**
44
+ * Normalize a phone to E.164 for Wolt, so a number given the local Israeli way
45
+ * still routes. Handles intl "+972…"/"00972…", national "0500000000", and bare
46
+ * local "500000000" alike. Idempotent.
47
+ */
48
+ export function normPhone(input) {
49
+ let digits = String(input).replace(/[^\d]/g, "");
50
+ if (digits.startsWith("00"))
51
+ digits = digits.slice(2); // intl exit code
52
+ if (digits.startsWith("972"))
53
+ return `+${digits}`;
54
+ if (digits.startsWith("0"))
55
+ return `+972${digits.slice(1)}`;
56
+ return `+972${digits}`;
57
+ }
58
+ export const formEncode = (body) => Object.entries(body)
59
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v ?? ""))}`)
60
+ .join("&");
61
+ /**
62
+ * A failed request.
63
+ *
64
+ * The body stays private. Wolt puts meaningful data in its 4xx bodies — the
65
+ * login escalation token, `error_code` — so the login flow needs to read it,
66
+ * but those same bodies carry access and refresh tokens. `details()` is the
67
+ * only way in, and the message never contains any of it.
68
+ */
69
+ export class WoltError extends Error {
70
+ status;
71
+ endpoint;
72
+ #body;
73
+ constructor(status, endpoint, body) {
74
+ const retired = status === 410 ? " (endpoint retired by Wolt)" : "";
75
+ super(`wolt: request failed (HTTP ${status})${retired} on ${endpoint}`);
76
+ this.name = "WoltError";
77
+ this.status = status;
78
+ this.endpoint = endpoint;
79
+ this.#body = body;
80
+ }
81
+ /** The parsed body, for the branches that must read it. Never logged. */
82
+ details() {
83
+ try {
84
+ return obj(JSON.parse(this.#body));
85
+ }
86
+ catch {
87
+ return {};
88
+ }
89
+ }
90
+ }
91
+ function endpointOf(host, path) {
92
+ return `${host}${path.split("?")[0]}`;
93
+ }
94
+ export async function http(host, path, opts = {}) {
95
+ const endpoint = endpointOf(host, path);
96
+ const text = await httpText(host, path, opts);
97
+ // Wolt answers 200 with nothing at all on endpoints it has retired.
98
+ if (!text.trim())
99
+ throw new Error(`wolt: ${endpoint} returned an empty body (endpoint retired)`);
100
+ try {
101
+ return obj(JSON.parse(text));
102
+ }
103
+ catch {
104
+ throw new Error(`wolt: non-JSON response from ${endpoint}`);
105
+ }
106
+ }
107
+ async function httpText(host, path, opts = {}) {
108
+ const { method = "GET", body = null, headers = {}, web = true } = opts;
109
+ const payload = body == null
110
+ ? null
111
+ : typeof body === "string"
112
+ ? body
113
+ : JSON.stringify(body);
114
+ const merged = {
115
+ ...(web ? WEB : {}),
116
+ accept: "application/json",
117
+ ...headers,
118
+ };
119
+ if (payload && !merged["content-type"])
120
+ merged["content-type"] = "application/json";
121
+ const endpoint = endpointOf(host, path);
122
+ const res = await authedFetch(`https://${host}${path}`, {
123
+ method,
124
+ headers: merged,
125
+ body: payload,
126
+ });
127
+ const text = await readBounded(res, MAX_RESPONSE_BYTES, `wolt: response exceeded the size limit on ${endpoint}`);
128
+ if (res.status >= 400)
129
+ throw new WoltError(res.status, endpoint, text);
130
+ return text;
131
+ }
132
+ // ---------- identity ----------
133
+ const ravelinDeviceId = () => `rvnand-6-${(randomUUID() + randomUUID()).replace(/-/g, "").toUpperCase().slice(0, 64)}`;
134
+ const hex32 = () => (randomUUID() + randomUUID()).replace(/-/g, "").slice(0, 32);
135
+ /**
136
+ * Device identity, generated once and then persisted. Wolt binds the session and
137
+ * its fraud fingerprint to these, so regenerating them invalidates the login.
138
+ */
139
+ export async function ensureIdentity(ctx) {
140
+ const cfg = cfgOf(ctx);
141
+ const patch = {};
142
+ if (!cfg.visitorId)
143
+ patch.visitorId = randomUUID();
144
+ if (!cfg.ravelinDeviceId)
145
+ patch.ravelinDeviceId = ravelinDeviceId();
146
+ if (!cfg.deviceToken)
147
+ patch.deviceToken = hex32();
148
+ if (!cfg.woltSessionId)
149
+ patch.woltSessionId = randomUUID();
150
+ if (Object.keys(patch).length)
151
+ await ctx.updateConnection(patch);
152
+ return cfgOf(ctx);
153
+ }
154
+ /** Headers every authenticated or login call carries. */
155
+ export function clientHeaders(cfg) {
156
+ return {
157
+ ...MOBILE,
158
+ "w-wolt-session-id": cfg.woltSessionId ?? randomUUID(),
159
+ "x-wolt-visitor-id": cfg.visitorId ?? randomUUID(),
160
+ };
161
+ }
162
+ // ---------- credentials ----------
163
+ export function expiresAt(expiresIn) {
164
+ const seconds = numOrNull(expiresIn);
165
+ return Date.now() + (seconds ? seconds * 1000 : DEFAULT_TOKEN_TTL_MS);
166
+ }
167
+ /**
168
+ * The refresh grant. Called only from inside an update owner and always with
169
+ * explicit values, so it can never re-enter the connection store.
170
+ */
171
+ async function refreshGrant(cfg) {
172
+ return http(AUTH, "/v1/wauth2/access_token", {
173
+ method: "POST",
174
+ web: false,
175
+ body: formEncode({
176
+ grant_type: "refresh_token",
177
+ refresh_token: cfg.refreshToken,
178
+ device_token: cfg.deviceToken ?? "",
179
+ }),
180
+ headers: {
181
+ ...clientHeaders(cfg),
182
+ "content-type": "application/x-www-form-urlencoded",
183
+ },
184
+ });
185
+ }
186
+ export async function accessToken(ctx, force = false) {
187
+ const cfg = await ensureIdentity(ctx);
188
+ if (!cfg.refreshToken)
189
+ throw new Error("wolt: not connected — run the owner login (account.requestEmailCode / account.requestSmsCode / account.redeemLink)");
190
+ // Renewal runs under the store's update ownership, so parallel actions
191
+ // coalesce onto one grant. Wolt rotates the refresh token on every grant, so
192
+ // a race would persist one the provider has already replaced.
193
+ return coordinatedAccessToken(ctx, async (current) => {
194
+ const resp = await refreshGrant(current);
195
+ const issued = pick(resp.access_token);
196
+ if (!issued)
197
+ throw new Error("wolt: token refresh returned no access_token — the refresh token has expired, re-run the owner login");
198
+ const rotated = pick(resp.refresh_token);
199
+ return {
200
+ accessToken: issued,
201
+ accessTokenExpiresAt: expiresAt(resp.expires_in),
202
+ ...(rotated ? { refreshToken: rotated } : {}),
203
+ };
204
+ }, force);
205
+ }
206
+ export async function authed(ctx, host, path, opts = {}) {
207
+ const token = await accessToken(ctx);
208
+ const cfg = cfgOf(ctx);
209
+ return http(host, path, {
210
+ ...opts,
211
+ web: false,
212
+ headers: {
213
+ ...clientHeaders(cfg),
214
+ authorization: `Bearer ${token}`,
215
+ ...(opts.headers ?? {}),
216
+ },
217
+ });
218
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Code mode for agents \u2014 turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",