@camstack/addon-import-alexa 0.1.1 → 0.1.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.
package/dist/addon.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
+ import { networkInterfaces } from "node:os";
3
+ import { randomBytes } from "node:crypto";
2
4
  import { promises } from "node:fs";
3
5
  import path from "node:path";
4
6
  //#region \0rolldown/runtime.js
@@ -6839,6 +6841,123 @@ var ConvertResultSchema = object({
6839
6841
  })).readonly()
6840
6842
  });
6841
6843
  /**
6844
+ * Build an `IAddonRouteProvider` from a list of routes. Implements
6845
+ * both the operator-facing `getRoutes` (returning route descriptors
6846
+ * minus the handlers, which can't cross JSON) and the framework-
6847
+ * private `invoke` method that the hub calls when this provider lives
6848
+ * in a forked worker.
6849
+ *
6850
+ * Co-located addons use the returned `getRoutes` directly because
6851
+ * their handlers don't need to cross any wire. The `invoke` method
6852
+ * is present anyway so the bridge code on the hub is uniform — it
6853
+ * doesn't need to switch on "local vs remote provider" at the call
6854
+ * site.
6855
+ *
6856
+ * Example:
6857
+ * const routes: IAddonHttpRoute[] = [
6858
+ * { method: 'GET', path: '/start', access: 'public', handler: this.handleStart },
6859
+ * ]
6860
+ * return [
6861
+ * {
6862
+ * capability: addonRoutesCapability,
6863
+ * provider: buildAddonRouteProvider('auth-oidc', routes),
6864
+ * },
6865
+ * ]
6866
+ */
6867
+ function buildAddonRouteProvider(id, routes) {
6868
+ return {
6869
+ id,
6870
+ getRoutes: () => routes,
6871
+ invoke: async (input) => {
6872
+ const match = matchRoute(routes, input.method, input.path);
6873
+ if (!match) return {
6874
+ status: 404,
6875
+ headers: {},
6876
+ redirectUrl: null,
6877
+ body: { error: `No route matches ${input.method} ${input.path}` }
6878
+ };
6879
+ const envelope = {
6880
+ status: 200,
6881
+ headers: {},
6882
+ redirectUrl: null
6883
+ };
6884
+ const reply = buildCapturingReply(envelope);
6885
+ const request = {
6886
+ params: {
6887
+ ...input.params,
6888
+ ...match.params
6889
+ },
6890
+ query: input.query,
6891
+ body: input.body,
6892
+ headers: input.headers,
6893
+ ...input.user ? { user: input.user } : {},
6894
+ ...input.scopedToken !== void 0 ? { scopedToken: input.scopedToken } : {}
6895
+ };
6896
+ await match.route.handler(request, reply);
6897
+ return envelope;
6898
+ }
6899
+ };
6900
+ }
6901
+ /**
6902
+ * Pattern matcher: same semantics as `AddonRouteRegistry.matchRoute`
6903
+ * but operating on a flat list and bypassing the `/addon/<id>/` prefix
6904
+ * — the bridge sends the post-prefix path directly so we don't need
6905
+ * to round-trip it through normalization.
6906
+ */
6907
+ function matchRoute(routes, method, path) {
6908
+ const normalizedMethod = method.toUpperCase();
6909
+ for (const route of routes) {
6910
+ if (route.method !== normalizedMethod) continue;
6911
+ const params = matchPath(route.path, path);
6912
+ if (params !== null) return {
6913
+ route,
6914
+ params
6915
+ };
6916
+ }
6917
+ return null;
6918
+ }
6919
+ function matchPath(pattern, p) {
6920
+ const patternParts = pattern.split("/").filter(Boolean);
6921
+ const pathParts = p.split("/").filter(Boolean);
6922
+ if (patternParts.length !== pathParts.length) return null;
6923
+ const params = {};
6924
+ for (let i = 0; i < patternParts.length; i++) {
6925
+ const a = patternParts[i];
6926
+ const b = pathParts[i];
6927
+ if (a.startsWith(":")) params[a.slice(1)] = b;
6928
+ else if (a !== b) return null;
6929
+ }
6930
+ return params;
6931
+ }
6932
+ function buildCapturingReply(envelope) {
6933
+ const wrapper = {
6934
+ status(code) {
6935
+ envelope.status = code;
6936
+ return wrapper;
6937
+ },
6938
+ code(code) {
6939
+ envelope.status = code;
6940
+ return wrapper;
6941
+ },
6942
+ send(data) {
6943
+ envelope.body = data;
6944
+ },
6945
+ redirect(url) {
6946
+ envelope.redirectUrl = url;
6947
+ if (envelope.status === 200) envelope.status = 302;
6948
+ },
6949
+ header(name, value) {
6950
+ envelope.headers[name.toLowerCase()] = value;
6951
+ return wrapper;
6952
+ },
6953
+ type(mime) {
6954
+ envelope.contentType = mime;
6955
+ return wrapper;
6956
+ }
6957
+ };
6958
+ return wrapper;
6959
+ }
6960
+ /**
6842
6961
  * Numeric day-of-week: 0 = Sunday … 6 = Saturday (matches `Date.getDay`).
6843
6962
  * Named `RecordingWeekday` to avoid collision with the string-union
6844
6963
  * `Weekday` exported from `interfaces/timezones.ts`.
@@ -15606,7 +15725,24 @@ var InvokeReplyEnvelopeSchema = object({
15606
15725
  /** Set when the handler called `reply.type(mime)`. */
15607
15726
  contentType: string().optional()
15608
15727
  });
15609
- method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
15728
+ var addonRoutesCapability = {
15729
+ name: "addon-routes",
15730
+ scope: "system",
15731
+ mode: "collection",
15732
+ internal: true,
15733
+ methods: {
15734
+ getRoutes: method(_void(), array(AddonHttpRouteSchema)),
15735
+ /**
15736
+ * Cross-process dispatch entry point. Forked addons implement this
15737
+ * (via `buildAddonRouteProvider`) so the hub's Fastify catch-all
15738
+ * can route through Moleculer when the handler lives in a worker.
15739
+ *
15740
+ * Local addons can implement it for free with the same helper;
15741
+ * the hub bypasses the wire on co-located addons.
15742
+ */
15743
+ invoke: method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" })
15744
+ }
15745
+ };
15610
15746
  /**
15611
15747
  * shm ring usage stats for a `frameSink: 'shm'` decoder session —
15612
15748
  * exposed via `decoder.getShmStats` so downstream consumers can
@@ -23940,6 +24076,122 @@ object({
23940
24076
  schemaVersion: literal(1)
23941
24077
  });
23942
24078
  //#endregion
24079
+ //#region src/config.ts
24080
+ /**
24081
+ * Alexa account connection settings for ONE broker (= one Amazon account).
24082
+ *
24083
+ * v1 uses the unofficial alexa-remote2 cookie / refresh-token flow. The operator
24084
+ * supplies the Amazon domain/region and either a captured cookie blob or a
24085
+ * refresh token (the proxy-login UX captures one of these). State is poll-only,
24086
+ * so a configurable poll interval is included.
24087
+ */
24088
+ var alexaConnectionSchema = object({
24089
+ /** Amazon domain/region. */
24090
+ amazonDomain: string().min(1).default("amazon.com").describe("Amazon domain (region)"),
24091
+ /**
24092
+ * Captured cookie material — either a raw cookie string OR the full
24093
+ * `cookieData` JSON registration blob produced by alexa-remote2's standalone
24094
+ * proxy login (preferred: it carries the refresh token + lets the session
24095
+ * auto-refresh). This is the primary auth input.
24096
+ */
24097
+ cookie: string().default("").describe("Captured Alexa cookie / cookieData blob (from proxy login)"),
24098
+ /**
24099
+ * Refresh token. NOTE: a bare refresh token alone is NOT sufficient to init
24100
+ * alexa-remote2 — supply the full cookie / cookieData blob (which embeds it).
24101
+ * Retained for provenance / forward-compat.
24102
+ */
24103
+ refreshToken: string().default("").describe("Alexa refresh token (must be inside cookie blob)"),
24104
+ /** Device-registration secret some flows need. */
24105
+ macDms: string().default("").describe("macDms device-registration secret (optional)"),
24106
+ /** Poll interval for state sync (Alexa has no clean 3rd-party push). */
24107
+ pollIntervalMs: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(15e3).max(36e5).default(6e4)).describe("State poll interval (ms) — conservative to avoid Amazon throttling")
24108
+ });
24109
+ /** Translate a stored connection into the client's credentials shape. */
24110
+ function connectionToCredentials(conn) {
24111
+ return {
24112
+ amazonDomain: conn.amazonDomain,
24113
+ ...conn.cookie.length > 0 ? { cookie: conn.cookie } : {},
24114
+ ...conn.refreshToken.length > 0 ? { refreshToken: conn.refreshToken } : {},
24115
+ ...conn.macDms.length > 0 ? { macDms: conn.macDms } : {}
24116
+ };
24117
+ }
24118
+ /** A single registered Alexa account ("broker"). */
24119
+ var AlexaBrokerEntrySchema = object({
24120
+ id: string().min(1),
24121
+ name: string().min(1),
24122
+ connection: alexaConnectionSchema,
24123
+ integrationId: string().optional()
24124
+ });
24125
+ /**
24126
+ * Interactive proxy-login settings (addon-global, not per-broker). alexa-remote2
24127
+ * spins a local reverse-proxy to Amazon's login page on `proxyHost:proxyPort`;
24128
+ * the operator opens that URL and logs in. `proxyHost` MUST be the host:port the
24129
+ * operator's browser uses (alexa-cookie2 rewrites Amazon URLs to absolute
24130
+ * `http://<proxyHost>:<proxyPort>/...`), so it is a directly-reachable host —
24131
+ * the hub's LAN IP/hostname — NOT behind the hub's HTTPS data-plane mount.
24132
+ * Empty `proxyHost` ⇒ auto-detect the host's primary non-loopback IPv4 at
24133
+ * login-start time.
24134
+ */
24135
+ var alexaProxyLoginSchema = object({
24136
+ proxyHost: string().default("").describe("Host/IP the browser uses to reach the login proxy (blank = auto-detect)"),
24137
+ proxyPort: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1).max(65535).default(3456)).describe("Port the local Amazon-login proxy listens on (must be reachable)")
24138
+ });
24139
+ /** Top-level addon config — broker entries + the proxy-login settings. */
24140
+ var alexaAddonConfigSchema = object({
24141
+ brokers: array(AlexaBrokerEntrySchema).default([]),
24142
+ proxyLogin: alexaProxyLoginSchema.default({
24143
+ proxyHost: "",
24144
+ proxyPort: 3456
24145
+ })
24146
+ });
24147
+ /** Narrow an `unknown` to a shallow plain-object record (cast-free boundary). */
24148
+ function toRecord(value) {
24149
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) return Object.fromEntries(Object.entries(value));
24150
+ return {};
24151
+ }
24152
+ /** Coerce a loose settings blob through the connection schema, applying defaults. */
24153
+ function settingsToAlexaConnection(settings) {
24154
+ return alexaConnectionSchema.parse(toRecord(settings));
24155
+ }
24156
+ /** Hand-written connection form for the broker/integration creation UI. */
24157
+ function buildConnectionFormSchema() {
24158
+ return { sections: [{
24159
+ id: "alexa-account",
24160
+ title: "Amazon Alexa account",
24161
+ description: "Link an Amazon account via the unofficial Alexa client. Supply a captured cookie OR a refresh token. NOTE: this is an unofficial integration — Amazon can change or break it without notice, and it may violate Amazon ToS. State is polled, not pushed.",
24162
+ columns: 1,
24163
+ fields: [
24164
+ {
24165
+ type: "text",
24166
+ key: "amazonDomain",
24167
+ label: "Amazon domain (region)",
24168
+ placeholder: "amazon.com",
24169
+ default: "amazon.com"
24170
+ },
24171
+ {
24172
+ type: "password",
24173
+ key: "cookie",
24174
+ label: "Cookie blob (from proxy login)",
24175
+ showToggle: true
24176
+ },
24177
+ {
24178
+ type: "password",
24179
+ key: "refreshToken",
24180
+ label: "Refresh token (alternative to cookie)",
24181
+ showToggle: true
24182
+ },
24183
+ {
24184
+ type: "number",
24185
+ key: "pollIntervalMs",
24186
+ label: "State poll interval (ms)",
24187
+ min: 15e3,
24188
+ max: 36e5,
24189
+ default: 6e4
24190
+ }
24191
+ ]
24192
+ }] };
24193
+ }
24194
+ //#endregion
23943
24195
  //#region ../../node_modules/extend/index.js
23944
24196
  var require_extend = /* @__PURE__ */ __commonJSMin(((exports, module) => {
23945
24197
  var hasOwn = Object.prototype.hasOwnProperty;
@@ -62524,7 +62776,7 @@ var require_alexa_cookie = /* @__PURE__ */ __commonJSMin(((exports, module) => {
62524
62776
  module.exports = AlexaCookie();
62525
62777
  }));
62526
62778
  //#endregion
62527
- //#region src/alexa-remote-decode.ts
62779
+ //#region src/alexa-proxy-login.ts
62528
62780
  var import_alexa_remote = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
62529
62781
  var https = __require("https");
62530
62782
  var querystring = __require("querystring");
@@ -65143,6 +65395,413 @@ var import_alexa_remote = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin
65143
65395
  module.exports = AlexaRemote;
65144
65396
  })))());
65145
65397
  /**
65398
+ * A single interactive proxy-login attempt. One instance = one Amazon login.
65399
+ * Construct → {@link start} → poll {@link getStatus} → on `captured` the
65400
+ * `onCookie` callback has fired with the blob → {@link dispose}.
65401
+ */
65402
+ var AlexaProxyLoginSession = class {
65403
+ #opts;
65404
+ #logger;
65405
+ #remote = null;
65406
+ #phase = "starting";
65407
+ #error = null;
65408
+ #startedAt = Date.now();
65409
+ #capturedAt = null;
65410
+ #captured = false;
65411
+ constructor(opts) {
65412
+ this.#opts = opts;
65413
+ this.#logger = opts.logger;
65414
+ }
65415
+ /** `http://<proxyOwnIp>:<proxyPort>/` — what the operator opens. */
65416
+ get loginUrl() {
65417
+ return `http://${this.#opts.proxyOwnIp}:${this.#opts.proxyPort}/`;
65418
+ }
65419
+ getStatus() {
65420
+ return {
65421
+ sessionId: this.#opts.sessionId,
65422
+ phase: this.#phase,
65423
+ loginUrl: this.#phase === "awaiting-login" ? this.loginUrl : "",
65424
+ amazonDomain: this.#opts.amazonDomain,
65425
+ error: this.#error,
65426
+ startedAt: this.#startedAt,
65427
+ capturedAt: this.#capturedAt
65428
+ };
65429
+ }
65430
+ /**
65431
+ * Spin up the proxy. Returns once `init` has been kicked off; the proxy then
65432
+ * binds asynchronously. alexa-remote2's `init` callback fires TWICE in proxy
65433
+ * mode (this is by design, not a bug):
65434
+ * 1. when the proxy is LISTENING, with an Error whose message is
65435
+ * "Please open http://… and login" — this is the "proxy ready" signal
65436
+ * (→ `awaiting-login`), NOT a failure;
65437
+ * 2. after a successful Amazon login, alexa-remote2 calls `setCookie` (→ the
65438
+ * `cookie` event, our real capture path) and auto-stops the proxy.
65439
+ * So `#onInitDone` must treat the "Please open" error as readiness and any
65440
+ * OTHER error as a genuine failure.
65441
+ */
65442
+ async start() {
65443
+ const remote = new import_alexa_remote.default();
65444
+ this.#remote = remote;
65445
+ const amazonPage = this.#opts.amazonDomain.length > 0 ? this.#opts.amazonDomain : "amazon.com";
65446
+ remote.on("cookie", () => this.#onCookieEvent(remote));
65447
+ remote.init({
65448
+ proxyOnly: true,
65449
+ proxyOwnIp: this.#opts.proxyOwnIp,
65450
+ proxyPort: this.#opts.proxyPort,
65451
+ proxyLogLevel: "warn",
65452
+ amazonPage,
65453
+ logger: (...args) => {
65454
+ const line = args.map(String).join(" ");
65455
+ if (SECRET_LINE_RE.test(line)) return;
65456
+ this.#logger.debug("alexa proxy-login", { meta: {
65457
+ sessionId: this.#opts.sessionId,
65458
+ line
65459
+ } });
65460
+ }
65461
+ }, (err) => this.#onInitDone(err));
65462
+ }
65463
+ /** Stop the proxy + the remote (idempotent). Safe to call multiple times. */
65464
+ async dispose() {
65465
+ const remote = this.#remote;
65466
+ this.#remote = null;
65467
+ if (remote === null) return;
65468
+ try {
65469
+ remote.stop();
65470
+ } catch (err) {
65471
+ this.#logger.warn("alexa proxy-login: dispose stop() failed", { meta: {
65472
+ sessionId: this.#opts.sessionId,
65473
+ error: errMsg(err)
65474
+ } });
65475
+ }
65476
+ }
65477
+ /** Mark cancelled + dispose. */
65478
+ async cancel() {
65479
+ if (this.#phase !== "captured") this.#phase = "cancelled";
65480
+ await this.dispose();
65481
+ }
65482
+ #onCookieEvent(remote) {
65483
+ if (this.#captured) return;
65484
+ const cookieData = remote.cookieData;
65485
+ const cookie = remote.cookie;
65486
+ if (cookieData === void 0 || cookieData === null) return;
65487
+ if (typeof cookie !== "string" || cookie.length === 0) return;
65488
+ const blob = isStringOrObject(cookieData) ? cookieData : null;
65489
+ if (blob === null) return;
65490
+ this.#captured = true;
65491
+ this.#capturedAt = Date.now();
65492
+ this.#phase = "captured";
65493
+ this.#logger.info("alexa proxy-login: cookie captured", { meta: { sessionId: this.#opts.sessionId } });
65494
+ try {
65495
+ this.#opts.onCookie(this.#opts.sessionId, {
65496
+ cookieData: blob,
65497
+ cookie
65498
+ });
65499
+ } catch (err) {
65500
+ this.#logger.warn("alexa proxy-login: onCookie handler threw", { meta: {
65501
+ sessionId: this.#opts.sessionId,
65502
+ error: errMsg(err)
65503
+ } });
65504
+ }
65505
+ this.dispose();
65506
+ }
65507
+ #onInitDone(err) {
65508
+ if (this.#captured || this.#phase === "cancelled") return;
65509
+ if (err === void 0) return;
65510
+ if (isProxyReadyError(err)) {
65511
+ if (this.#phase === "starting") {
65512
+ this.#phase = "awaiting-login";
65513
+ this.#logger.info("alexa proxy-login: proxy ready — awaiting Amazon login", { meta: {
65514
+ sessionId: this.#opts.sessionId,
65515
+ loginUrl: this.loginUrl
65516
+ } });
65517
+ }
65518
+ return;
65519
+ }
65520
+ this.#phase = "error";
65521
+ this.#error = errMsg(err);
65522
+ this.#logger.warn("alexa proxy-login: init failed", { meta: {
65523
+ sessionId: this.#opts.sessionId,
65524
+ error: this.#error
65525
+ } });
65526
+ this.dispose();
65527
+ }
65528
+ };
65529
+ /**
65530
+ * Lines emitted by alexa-cookie2 that contain raw credential material
65531
+ * (cookie values, CSRF tokens, bearer tokens). Any log line matching this
65532
+ * pattern is silently dropped — it must never reach the structured logger or
65533
+ * Loki.
65534
+ *
65535
+ * Covers patterns such as:
65536
+ * "Alexa-Cookie: Update Cookie <name>=<value>"
65537
+ * "Result: csrf=…, Cookie=…"
65538
+ * "Set-Cookie: …"
65539
+ */
65540
+ var SECRET_LINE_RE = /cookie|csrf|token/i;
65541
+ /**
65542
+ * alexa-remote2 signals "proxy ready, go log in" by invoking `init`'s callback
65543
+ * with an Error carrying the "Please open http://…/ with your browser and login"
65544
+ * message (alexa-cookie2 `generateAlexaCookie` listening callback). Detect it so
65545
+ * we treat readiness as success, not failure.
65546
+ */
65547
+ function isProxyReadyError(err) {
65548
+ const m = errMsg(err).toLowerCase();
65549
+ return m.includes("please open") && m.includes("login");
65550
+ }
65551
+ /** True for the `string | object` forms alexa-remote2 accepts back as `cookie`. */
65552
+ function isStringOrObject(value) {
65553
+ return typeof value === "string" || typeof value === "object" && value !== null;
65554
+ }
65555
+ //#endregion
65556
+ //#region src/alexa-proxy-login-manager.ts
65557
+ var AlexaProxyLoginManager = class {
65558
+ #logger;
65559
+ #getProxyConfig;
65560
+ #session = null;
65561
+ #captured = /* @__PURE__ */ new Map();
65562
+ constructor(deps) {
65563
+ this.#logger = deps.logger;
65564
+ this.#getProxyConfig = deps.getProxyConfig;
65565
+ }
65566
+ /**
65567
+ * Start a fresh proxy-login. Cancels any in-flight session first (single
65568
+ * fixed port). Returns the `loginUrl` for the onboarding UI to open.
65569
+ */
65570
+ async start(amazonDomain) {
65571
+ await this.cancelActive();
65572
+ const { proxyHost, proxyPort } = this.#getProxyConfig();
65573
+ const host = proxyHost.length > 0 ? proxyHost : detectPrimaryIpv4();
65574
+ if (host.length === 0) throw new Error("alexa proxy-login: could not auto-detect a reachable host IP — set proxyHost in settings");
65575
+ const sessionId = randomBytes(9).toString("base64url");
65576
+ const session = new AlexaProxyLoginSession({
65577
+ sessionId,
65578
+ amazonDomain,
65579
+ proxyOwnIp: host,
65580
+ proxyPort,
65581
+ logger: this.#logger,
65582
+ onCookie: (id, captured) => {
65583
+ this.#captured.set(id, captured);
65584
+ }
65585
+ });
65586
+ this.#session = session;
65587
+ try {
65588
+ await session.start();
65589
+ } catch (err) {
65590
+ this.#session = null;
65591
+ await session.dispose();
65592
+ throw new Error(`alexa proxy-login: failed to start proxy — ${errMsg(err)}`);
65593
+ }
65594
+ return {
65595
+ sessionId,
65596
+ loginUrl: session.loginUrl,
65597
+ amazonDomain
65598
+ };
65599
+ }
65600
+ /** Live status for a session id (or null when it isn't the active one). */
65601
+ status(sessionId) {
65602
+ const session = this.#session;
65603
+ if (session === null) return null;
65604
+ const status = session.getStatus();
65605
+ return status.sessionId === sessionId ? status : null;
65606
+ }
65607
+ /**
65608
+ * Pull (and consume) the captured cookie material for a session. Returns null
65609
+ * when nothing has been captured yet. The caller commits it onto a broker.
65610
+ */
65611
+ takeCaptured(sessionId) {
65612
+ const captured = this.#captured.get(sessionId) ?? null;
65613
+ if (captured !== null) this.#captured.delete(sessionId);
65614
+ return captured;
65615
+ }
65616
+ /** Cancel a specific session (no-op when it isn't active). */
65617
+ async cancel(sessionId) {
65618
+ const session = this.#session;
65619
+ if (session === null) return;
65620
+ if (session.getStatus().sessionId !== sessionId) return;
65621
+ await this.cancelActive();
65622
+ this.#captured.delete(sessionId);
65623
+ }
65624
+ /** Cancel + dispose whatever session is currently active. */
65625
+ async cancelActive() {
65626
+ const session = this.#session;
65627
+ this.#session = null;
65628
+ if (session === null) return;
65629
+ try {
65630
+ await session.cancel();
65631
+ } catch (err) {
65632
+ this.#logger.warn("alexa proxy-login: cancelActive failed", { meta: { error: errMsg(err) } });
65633
+ }
65634
+ }
65635
+ /** Full teardown on addon shutdown. */
65636
+ async dispose() {
65637
+ await this.cancelActive();
65638
+ this.#captured.clear();
65639
+ }
65640
+ };
65641
+ /**
65642
+ * Best-effort primary non-loopback IPv4 of the host. Used when the operator
65643
+ * leaves `proxyHost` blank. Returns '' when none found (then the operator must
65644
+ * set it explicitly — common on multi-homed/containerised hosts).
65645
+ */
65646
+ function detectPrimaryIpv4() {
65647
+ const ifaces = networkInterfaces();
65648
+ for (const addrs of Object.values(ifaces)) {
65649
+ if (addrs === void 0) continue;
65650
+ for (const addr of addrs) if (addr.family === "IPv4" && !addr.internal && addr.address.length > 0) return addr.address;
65651
+ }
65652
+ return "";
65653
+ }
65654
+ /**
65655
+ * Serialize the captured `cookieData` blob into the single STRING the broker
65656
+ * connection stores in its `cookie` field. `AlexaRemoteClient` already parses a
65657
+ * JSON-object cookie string back into the richer object form on init, so we
65658
+ * persist the object as JSON; a bare string passes through unchanged.
65659
+ */
65660
+ function capturedToCookieString(captured) {
65661
+ const { cookieData } = captured;
65662
+ if (typeof cookieData === "string") return cookieData;
65663
+ try {
65664
+ return JSON.stringify(cookieData);
65665
+ } catch {
65666
+ return captured.cookie;
65667
+ }
65668
+ }
65669
+ //#endregion
65670
+ //#region src/alexa-login-routes.ts
65671
+ var StartBodySchema = object({ amazonDomain: _enum([
65672
+ "amazon.com",
65673
+ "amazon.it",
65674
+ "amazon.de",
65675
+ "amazon.co.uk",
65676
+ "amazon.fr",
65677
+ "amazon.es",
65678
+ "amazon.ca",
65679
+ "amazon.com.au",
65680
+ "amazon.co.jp",
65681
+ "amazon.in",
65682
+ "amazon.com.br",
65683
+ "amazon.com.mx"
65684
+ ]).default("amazon.com") });
65685
+ var StatusQuerySchema = object({ sessionId: string().min(1) });
65686
+ var CommitBodySchema = object({
65687
+ sessionId: string().min(1),
65688
+ /** Name for a NEW broker (when `brokerId` is omitted). */
65689
+ name: string().min(1).default("Alexa account"),
65690
+ /** Existing broker to RE-LINK; omit to create a fresh broker. */
65691
+ brokerId: string().min(1).optional()
65692
+ });
65693
+ var CancelBodySchema = object({ sessionId: string().min(1) });
65694
+ /** Parse a JSON body that may arrive as an object or a raw string. */
65695
+ function asRecord(body) {
65696
+ if (typeof body === "object" && body !== null && !Array.isArray(body)) return Object.fromEntries(Object.entries(body));
65697
+ if (typeof body === "string" && body.length > 0) try {
65698
+ const parsed = JSON.parse(body);
65699
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return Object.fromEntries(Object.entries(parsed));
65700
+ } catch {}
65701
+ return {};
65702
+ }
65703
+ function buildAlexaLoginRoutes(deps) {
65704
+ const { logger, proxyLogin, commitCookie } = deps;
65705
+ return [
65706
+ {
65707
+ method: "POST",
65708
+ path: "/login/start",
65709
+ access: "admin",
65710
+ description: "Start the interactive Amazon proxy-login; returns the URL to open",
65711
+ handler: async (req, reply) => {
65712
+ const parsed = StartBodySchema.safeParse(asRecord(req.body));
65713
+ if (!parsed.success) {
65714
+ reply.code(400).send({
65715
+ error: "invalid body",
65716
+ detail: parsed.error.message
65717
+ });
65718
+ return;
65719
+ }
65720
+ try {
65721
+ const result = await proxyLogin.start(parsed.data.amazonDomain);
65722
+ reply.code(200).send(result);
65723
+ } catch (err) {
65724
+ logger.warn("alexa login/start failed", { meta: { error: errMsg(err) } });
65725
+ reply.code(500).send({ error: errMsg(err) });
65726
+ }
65727
+ }
65728
+ },
65729
+ {
65730
+ method: "GET",
65731
+ path: "/login/status",
65732
+ access: "admin",
65733
+ description: "Poll the interactive login session status (no secrets returned)",
65734
+ handler: async (req, reply) => {
65735
+ const parsed = StatusQuerySchema.safeParse(req.query);
65736
+ if (!parsed.success) {
65737
+ reply.code(400).send({ error: "missing sessionId" });
65738
+ return;
65739
+ }
65740
+ const status = proxyLogin.status(parsed.data.sessionId);
65741
+ if (status === null) {
65742
+ reply.code(404).send({ error: "unknown or expired session" });
65743
+ return;
65744
+ }
65745
+ reply.code(200).send(status);
65746
+ }
65747
+ },
65748
+ {
65749
+ method: "POST",
65750
+ path: "/login/commit",
65751
+ access: "admin",
65752
+ description: "Persist the captured cookie onto a new or existing broker",
65753
+ handler: async (req, reply) => {
65754
+ const parsed = CommitBodySchema.safeParse(asRecord(req.body));
65755
+ if (!parsed.success) {
65756
+ reply.code(400).send({
65757
+ error: "invalid body",
65758
+ detail: parsed.error.message
65759
+ });
65760
+ return;
65761
+ }
65762
+ const { sessionId, name, brokerId } = parsed.data;
65763
+ const status = proxyLogin.status(sessionId);
65764
+ const captured = proxyLogin.takeCaptured(sessionId);
65765
+ if (captured === null) {
65766
+ reply.code(409).send({ error: "no captured cookie for this session (login not completed yet?)" });
65767
+ return;
65768
+ }
65769
+ const amazonDomain = status?.amazonDomain ?? "amazon.com";
65770
+ try {
65771
+ const result = await commitCookie({
65772
+ cookie: capturedToCookieString(captured),
65773
+ amazonDomain,
65774
+ name,
65775
+ ...brokerId !== void 0 ? { brokerId } : {}
65776
+ });
65777
+ await proxyLogin.cancel(sessionId);
65778
+ reply.code(200).send(result);
65779
+ } catch (err) {
65780
+ logger.warn("alexa login/commit failed", { meta: { error: errMsg(err) } });
65781
+ reply.code(500).send({ error: errMsg(err) });
65782
+ }
65783
+ }
65784
+ },
65785
+ {
65786
+ method: "POST",
65787
+ path: "/login/cancel",
65788
+ access: "admin",
65789
+ description: "Cancel an in-flight interactive login session",
65790
+ handler: async (req, reply) => {
65791
+ const parsed = CancelBodySchema.safeParse(asRecord(req.body));
65792
+ if (!parsed.success) {
65793
+ reply.code(400).send({ error: "missing sessionId" });
65794
+ return;
65795
+ }
65796
+ await proxyLogin.cancel(parsed.data.sessionId);
65797
+ reply.code(200).send({ ok: true });
65798
+ }
65799
+ }
65800
+ ];
65801
+ }
65802
+ //#endregion
65803
+ //#region src/alexa-remote-decode.ts
65804
+ /**
65146
65805
  * Pure, total decoders translating `alexa-remote2` JSON response bodies into the
65147
65806
  * controller-agnostic shapes the addon consumes ({@link AlexaAppliance} /
65148
65807
  * {@link AlexaStateValue}). NO `alexa-remote2` import — these run against plain
@@ -65732,100 +66391,6 @@ var AlexaCommandSink = class {
65732
66391
  /** The single in-process command sink shared across the addon. */
65733
66392
  var alexaCommands = new AlexaCommandSink();
65734
66393
  //#endregion
65735
- //#region src/config.ts
65736
- /**
65737
- * Alexa account connection settings for ONE broker (= one Amazon account).
65738
- *
65739
- * v1 uses the unofficial alexa-remote2 cookie / refresh-token flow. The operator
65740
- * supplies the Amazon domain/region and either a captured cookie blob or a
65741
- * refresh token (the proxy-login UX captures one of these). State is poll-only,
65742
- * so a configurable poll interval is included.
65743
- */
65744
- var alexaConnectionSchema = object({
65745
- /** Amazon domain/region. */
65746
- amazonDomain: string().min(1).default("amazon.com").describe("Amazon domain (region)"),
65747
- /**
65748
- * Captured cookie material — either a raw cookie string OR the full
65749
- * `cookieData` JSON registration blob produced by alexa-remote2's standalone
65750
- * proxy login (preferred: it carries the refresh token + lets the session
65751
- * auto-refresh). This is the primary auth input.
65752
- */
65753
- cookie: string().default("").describe("Captured Alexa cookie / cookieData blob (from proxy login)"),
65754
- /**
65755
- * Refresh token. NOTE: a bare refresh token alone is NOT sufficient to init
65756
- * alexa-remote2 — supply the full cookie / cookieData blob (which embeds it).
65757
- * Retained for provenance / forward-compat.
65758
- */
65759
- refreshToken: string().default("").describe("Alexa refresh token (must be inside cookie blob)"),
65760
- /** Device-registration secret some flows need. */
65761
- macDms: string().default("").describe("macDms device-registration secret (optional)"),
65762
- /** Poll interval for state sync (Alexa has no clean 3rd-party push). */
65763
- pollIntervalMs: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(15e3).max(36e5).default(6e4)).describe("State poll interval (ms) — conservative to avoid Amazon throttling")
65764
- });
65765
- /** Translate a stored connection into the client's credentials shape. */
65766
- function connectionToCredentials(conn) {
65767
- return {
65768
- amazonDomain: conn.amazonDomain,
65769
- ...conn.cookie.length > 0 ? { cookie: conn.cookie } : {},
65770
- ...conn.refreshToken.length > 0 ? { refreshToken: conn.refreshToken } : {},
65771
- ...conn.macDms.length > 0 ? { macDms: conn.macDms } : {}
65772
- };
65773
- }
65774
- /** Top-level addon config — an ordered list of broker entries (default empty). */
65775
- var alexaAddonConfigSchema = object({ brokers: array(object({
65776
- id: string().min(1),
65777
- name: string().min(1),
65778
- connection: alexaConnectionSchema,
65779
- integrationId: string().optional()
65780
- })).default([]) });
65781
- /** Narrow an `unknown` to a shallow plain-object record (cast-free boundary). */
65782
- function toRecord(value) {
65783
- if (typeof value === "object" && value !== null && !Array.isArray(value)) return Object.fromEntries(Object.entries(value));
65784
- return {};
65785
- }
65786
- /** Coerce a loose settings blob through the connection schema, applying defaults. */
65787
- function settingsToAlexaConnection(settings) {
65788
- return alexaConnectionSchema.parse(toRecord(settings));
65789
- }
65790
- /** Hand-written connection form for the broker/integration creation UI. */
65791
- function buildConnectionFormSchema() {
65792
- return { sections: [{
65793
- id: "alexa-account",
65794
- title: "Amazon Alexa account",
65795
- description: "Link an Amazon account via the unofficial Alexa client. Supply a captured cookie OR a refresh token. NOTE: this is an unofficial integration — Amazon can change or break it without notice, and it may violate Amazon ToS. State is polled, not pushed.",
65796
- columns: 1,
65797
- fields: [
65798
- {
65799
- type: "text",
65800
- key: "amazonDomain",
65801
- label: "Amazon domain (region)",
65802
- placeholder: "amazon.com",
65803
- default: "amazon.com"
65804
- },
65805
- {
65806
- type: "password",
65807
- key: "cookie",
65808
- label: "Cookie blob (from proxy login)",
65809
- showToggle: true
65810
- },
65811
- {
65812
- type: "password",
65813
- key: "refreshToken",
65814
- label: "Refresh token (alternative to cookie)",
65815
- showToggle: true
65816
- },
65817
- {
65818
- type: "number",
65819
- key: "pollIntervalMs",
65820
- label: "State poll interval (ms)",
65821
- min: 15e3,
65822
- max: 36e5,
65823
- default: 6e4
65824
- }
65825
- ]
65826
- }] };
65827
- }
65828
- //#endregion
65829
66394
  //#region src/alexa-integration-manager.ts
65830
66395
  /**
65831
66396
  * Wraps exactly one {@link AlexaClient} (= one Amazon account). Authenticates on
@@ -66992,7 +67557,13 @@ function isKnownCap(cap) {
66992
67557
  //#endregion
66993
67558
  //#region src/addon.ts
66994
67559
  /** Default config — a fresh install starts with no accounts. */
66995
- var DEFAULTS = { brokers: [] };
67560
+ var DEFAULTS = {
67561
+ brokers: [],
67562
+ proxyLogin: {
67563
+ proxyHost: "",
67564
+ proxyPort: 3456
67565
+ }
67566
+ };
66996
67567
  /** Device types an imported Alexa appliance can materialise as. */
66997
67568
  var ALEXA_DEVICE_TYPES = [
66998
67569
  DeviceType.Switch,
@@ -67015,15 +67586,21 @@ var ALEXA_DEVICE_TYPES = [
67015
67586
  *
67016
67587
  * The Alexa cloud client is the real alexa-remote2 binding (`AlexaRemoteClient`,
67017
67588
  * see `alexa-remote-client.ts`); `StubAlexaClient` remains as a test double.
67018
- * Auth is cookie / cookieData-blob based (no interactive proxy login inside the
67019
- * hub) supply a pre-obtained cookie from settings; the session is persisted to
67020
- * the addon `dataDir` and auto-refreshed.
67589
+ * Auth is cookie / cookieData-blob based. The preferred onboarding path is the
67590
+ * interactive proxy login (alexa-remote2 proxy auth mode, driven by the
67591
+ * onboarding wizard via the addon-routes control plane): the operator opens a
67592
+ * local proxy URL in their browser, completes the real Amazon OAuth/2FA flow,
67593
+ * and the captured cookie is persisted automatically. Pasting a pre-obtained
67594
+ * cookie is kept as a fallback. Either way the cookie lands in the same
67595
+ * `connection.cookie` field; the session is persisted to the addon `dataDir`
67596
+ * and auto-refreshed.
67021
67597
  */
67022
67598
  var AlexaImportAddon = class extends BaseDeviceProvider {
67023
67599
  addonId = "import-alexa";
67024
67600
  providerName = "Alexa Import";
67025
67601
  deviceClasses = Object.fromEntries(ALEXA_DEVICE_TYPES.map((t) => [t, AlexaDevice]));
67026
67602
  registry = null;
67603
+ proxyLogin = null;
67027
67604
  constructor() {
67028
67605
  super({ ...DEFAULTS });
67029
67606
  }
@@ -67054,9 +67631,24 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
67054
67631
  });
67055
67632
  alexaCommands.setDispatcher((brokerId, command) => this.requireRegistry().sendCommand(brokerId, command));
67056
67633
  await this.registry.restore(this.config.brokers);
67634
+ this.proxyLogin = new AlexaProxyLoginManager({
67635
+ logger: this.ctx.logger,
67636
+ getProxyConfig: () => {
67637
+ const cfg = alexaProxyLoginSchema.parse(this.config.proxyLogin ?? {});
67638
+ return {
67639
+ proxyHost: cfg.proxyHost,
67640
+ proxyPort: cfg.proxyPort
67641
+ };
67642
+ }
67643
+ });
67057
67644
  this.ctx.logger.info("Alexa: import addon initialised", { meta: { brokerCount: this.config.brokers.length } });
67058
67645
  await this.reconcileIntegrationsToBrokers();
67059
67646
  this.subscribeIntegrationLifecycle();
67647
+ const loginRoutes = buildAlexaLoginRoutes({
67648
+ logger: this.ctx.logger,
67649
+ proxyLogin: this.proxyLogin,
67650
+ commitCookie: (input) => this.commitCapturedCookie(input)
67651
+ });
67060
67652
  return [
67061
67653
  ...regs,
67062
67654
  {
@@ -67066,6 +67658,10 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
67066
67658
  {
67067
67659
  capability: deviceAdoptionCapability,
67068
67660
  provider: this.buildAdoptionProvider()
67661
+ },
67662
+ {
67663
+ capability: addonRoutesCapability,
67664
+ provider: buildAddonRouteProvider("import-alexa", loginRoutes)
67069
67665
  }
67070
67666
  ];
67071
67667
  }
@@ -67115,6 +67711,12 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
67115
67711
  }
67116
67712
  async onShutdown() {
67117
67713
  alexaCommands.clear();
67714
+ try {
67715
+ await this.proxyLogin?.dispose();
67716
+ } catch (err) {
67717
+ this.ctx.logger.warn("Alexa: proxy-login dispose error", { meta: { error: errMsg(err) } });
67718
+ }
67719
+ this.proxyLogin = null;
67118
67720
  try {
67119
67721
  await this.registry?.shutdown();
67120
67722
  } catch (err) {
@@ -67191,6 +67793,40 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
67191
67793
  logger: this.ctx.logger
67192
67794
  });
67193
67795
  }
67796
+ /**
67797
+ * Persist a cookie captured by the interactive proxy-login onto a broker. With
67798
+ * a `brokerId` the broker is RE-LINKED (credentials swapped, devices kept);
67799
+ * otherwise a NEW broker is created. The captured cookie goes into exactly the
67800
+ * same `connection.cookie` field a pasted cookie would — so the registry /
67801
+ * poll / adoption layers are unchanged.
67802
+ */
67803
+ async commitCapturedCookie(input) {
67804
+ const reg = this.requireRegistry();
67805
+ if (input.brokerId !== void 0) {
67806
+ const existing = this.config.brokers.find((b) => b.id === input.brokerId);
67807
+ if (existing === void 0) throw new Error(`commitCookie: unknown broker "${input.brokerId}"`);
67808
+ const connection = {
67809
+ ...existing.connection,
67810
+ amazonDomain: input.amazonDomain,
67811
+ cookie: input.cookie
67812
+ };
67813
+ const updated = await reg.updateEntry(input.brokerId, connection);
67814
+ await this.updateGlobalSettings({ brokers: this.config.brokers.map((b) => b.id === input.brokerId ? updated : b) });
67815
+ this.ctx.logger.info("Alexa: proxy-login cookie committed to existing broker", { meta: { brokerId: input.brokerId } });
67816
+ return { brokerId: input.brokerId };
67817
+ }
67818
+ const connection = settingsToAlexaConnection({
67819
+ amazonDomain: input.amazonDomain,
67820
+ cookie: input.cookie
67821
+ });
67822
+ const entry = await reg.createEntry(input.name, connection);
67823
+ await this.updateGlobalSettings({ brokers: [...this.config.brokers, entry] });
67824
+ this.ctx.logger.info("Alexa: proxy-login cookie committed to new broker", { meta: {
67825
+ brokerId: entry.id,
67826
+ name: input.name
67827
+ } });
67828
+ return { brokerId: entry.id };
67829
+ }
67194
67830
  async cascadeRemoveDevicesForBroker(brokerId) {
67195
67831
  const reg = this.ctx.kernel.deviceRegistry;
67196
67832
  const devices = this.ctx.kernel.devices;
@@ -67319,6 +67955,24 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
67319
67955
  label: "Accounts are managed separately",
67320
67956
  content: "This integration links to an Amazon Alexa account broker. Add, edit, or remove accounts from External systems → Brokers, then adopt the imported devices. The integration only stores a reference to its broker. NOTE: this is an unofficial integration that Amazon can change or break without notice."
67321
67957
  }]
67958
+ }, {
67959
+ id: "alexa-proxy-login",
67960
+ title: "Interactive login (proxy)",
67961
+ description: "The \"Login to Amazon\" button in onboarding opens a local proxy that captures the cookie after you complete the real Amazon login (incl. 2FA). Because Amazon URLs are rewritten to this exact host:port, it must be a directly-reachable address — leave the host blank to auto-detect the server IP, and make sure the port is reachable from the browser you log in from.",
67962
+ columns: 2,
67963
+ fields: [{
67964
+ type: "text",
67965
+ key: "proxyLogin.proxyHost",
67966
+ label: "Login proxy host/IP (blank = auto-detect)",
67967
+ placeholder: "auto-detect"
67968
+ }, {
67969
+ type: "number",
67970
+ key: "proxyLogin.proxyPort",
67971
+ label: "Login proxy port",
67972
+ min: 1,
67973
+ max: 65535,
67974
+ default: 3456
67975
+ }]
67322
67976
  }] });
67323
67977
  }
67324
67978
  async supportsManualCreation() {
@@ -67353,4 +68007,4 @@ function capsEqual(a, b) {
67353
68007
  return true;
67354
68008
  }
67355
68009
  //#endregion
67356
- export { AlexaImportAddon, alexaConnectionSchema as a, settingsToAlexaConnection as c, StubAlexaClient as d, createAlexaClient as f, alexaAddonConfigSchema as i, AlexaAuthError as l, mappableAppliance as n, buildConnectionFormSchema as o, mapAlexaCapabilitiesToCamStack as r, connectionToCredentials as s, buildAlexaApplianceCandidate as t, AlexaClientNotWiredError as u };
68010
+ export { AlexaImportAddon, AlexaClientNotWiredError as a, alexaAddonConfigSchema as c, connectionToCredentials as d, settingsToAlexaConnection as f, AlexaAuthError as i, alexaConnectionSchema as l, mappableAppliance as n, StubAlexaClient as o, mapAlexaCapabilitiesToCamStack as r, createAlexaClient as s, buildAlexaApplianceCandidate as t, buildConnectionFormSchema as u };