@camstack/addon-google-services 0.1.7 → 0.2.7

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.
@@ -13666,7 +13666,14 @@ var BrokerProviderInfoSchema = object({
13666
13666
  /** Broker kinds this provider can create, with a display label. */
13667
13667
  kinds: array(object({
13668
13668
  kind: string(),
13669
- label: string()
13669
+ label: string(),
13670
+ /**
13671
+ * Relative hub path to start an interactive account link (OAuth popup).
13672
+ * May include `{{brokerId}}`; admin-ui substitutes and appends `?popup=1`.
13673
+ */
13674
+ interactiveConnectPath: string().min(1).optional(),
13675
+ /** Relative hub path to disconnect the linked account (same popup pattern). */
13676
+ interactiveDisconnectPath: string().min(1).optional()
13670
13677
  }))
13671
13678
  });
13672
13679
  var ListInputSchema = object({
@@ -13752,34 +13759,83 @@ var GetStateInputSchema = object({
13752
13759
  * HA: entity_id (returns the cached entity state). */
13753
13760
  key: string()
13754
13761
  });
13755
- method(ListInputSchema, array(BrokerInfoSchema)), method(GetInputSchema, BrokerInfoSchema.nullable()), method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }), method(AddInputSchema, AddResultSchema, {
13756
- kind: "mutation",
13757
- auth: "admin"
13758
- }), method(RemoveInputSchema, _void(), {
13759
- kind: "mutation",
13760
- auth: "admin"
13761
- }), method(GetInputSchema, TestConnectionResultSchema, {
13762
- kind: "mutation",
13763
- auth: "admin"
13764
- }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(object({
13765
- id: string(),
13766
- settings: SettingsRecordSchema$1
13767
- }), _void(), {
13768
- kind: "mutation",
13769
- auth: "admin"
13770
- }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }), method(TestSettingsInputSchema, TestSettingsResultSchema, {
13771
- kind: "mutation",
13772
- auth: "admin"
13773
- }), method(PublishInputSchema, unknown(), {
13774
- kind: "mutation",
13775
- auth: "admin"
13776
- }), method(SubscribeInputSchema, SubscribeResultSchema, {
13777
- kind: "mutation",
13778
- auth: "admin"
13779
- }), method(UnsubscribeInputSchema, _void(), {
13780
- kind: "mutation",
13781
- auth: "admin"
13782
- }), method(GetStateInputSchema, unknown().nullable(), { auth: "admin" }), method(_void(), RegistryStatusSchema);
13762
+ var brokerCapability = {
13763
+ name: "broker",
13764
+ scope: "system",
13765
+ mode: "collection",
13766
+ providerKind: "broker",
13767
+ status: {
13768
+ schema: RegistryStatusSchema,
13769
+ kind: "poll"
13770
+ },
13771
+ methods: {
13772
+ list: method(ListInputSchema, array(BrokerInfoSchema)),
13773
+ get: method(GetInputSchema, BrokerInfoSchema.nullable()),
13774
+ /** Enumerate which addon provides which broker kind(s) for the
13775
+ * unified create picker. The auto-mount fans this array across
13776
+ * every registered `broker` provider (array-output method), so the
13777
+ * picker sees every kind from every provider in one call. */
13778
+ listProviders: method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }),
13779
+ add: method(AddInputSchema, AddResultSchema, {
13780
+ kind: "mutation",
13781
+ auth: "admin"
13782
+ }),
13783
+ remove: method(RemoveInputSchema, _void(), {
13784
+ kind: "mutation",
13785
+ auth: "admin"
13786
+ }),
13787
+ testConnection: method(GetInputSchema, TestConnectionResultSchema, {
13788
+ kind: "mutation",
13789
+ auth: "admin"
13790
+ }),
13791
+ /** Read the persisted settings record for a broker (kind-specific
13792
+ * shape). Admin-only — settings may contain secrets. Returns `null`
13793
+ * when the broker id is unknown to the provider (the collection
13794
+ * fallback may route a foreign id to the first provider). */
13795
+ getSettings: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
13796
+ /** Overwrite the persisted settings record. The kind-specific
13797
+ * provider validates the shape and applies the change (reconnects
13798
+ * if credentials changed). */
13799
+ setSettings: method(object({
13800
+ id: string(),
13801
+ settings: SettingsRecordSchema$1
13802
+ }), _void(), {
13803
+ kind: "mutation",
13804
+ auth: "admin"
13805
+ }),
13806
+ /** Returns the kind-specific connection config the consumer needs
13807
+ * to open its own client (MQTT pattern: `{url, username, password,
13808
+ * clientIdPrefix}`). HA providers MAY return the auth envelope
13809
+ * but typical HA consumers use `publish` / `subscribe` instead.
13810
+ * Returns `null` when the broker id is unknown to the provider. */
13811
+ getBrokerConfig: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
13812
+ getSettingsSchema: method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }),
13813
+ testSettings: method(TestSettingsInputSchema, TestSettingsResultSchema, {
13814
+ kind: "mutation",
13815
+ auth: "admin"
13816
+ }),
13817
+ publish: method(PublishInputSchema, unknown(), {
13818
+ kind: "mutation",
13819
+ auth: "admin"
13820
+ }),
13821
+ subscribe: method(SubscribeInputSchema, SubscribeResultSchema, {
13822
+ kind: "mutation",
13823
+ auth: "admin"
13824
+ }),
13825
+ unsubscribe: method(UnsubscribeInputSchema, _void(), {
13826
+ kind: "mutation",
13827
+ auth: "admin"
13828
+ }),
13829
+ /** Read the broker's cached state for a key. Returns `null` when
13830
+ * unknown to the broker (never published / unknown entity). */
13831
+ getState: method(GetStateInputSchema, unknown().nullable(), { auth: "admin" }),
13832
+ /** Status method — explicit registration with a `z.void()` input so
13833
+ * the codegen-generated tRPC router types its input as
13834
+ * `{addonId?: string, nodeId?: string}` (system-scoped collection
13835
+ * shape) instead of the device-scoped `{deviceId}` fallback. */
13836
+ getStatus: method(_void(), RegistryStatusSchema)
13837
+ }
13838
+ };
13783
13839
  DeviceType.Camera;
13784
13840
  /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
13785
13841
  var StreamFormatSchema = _enum([
@@ -52885,34 +52941,506 @@ var MB = 1024 * 1024;
52885
52941
  1024 * MB, 3072 * MB;
52886
52942
  //#endregion
52887
52943
  //#region src/google-types.ts
52888
- var GoogleServicesConfigSchema = object({
52944
+ /** One Google account connection (broker entry). Credentials for the OAuth
52945
+ * *app* live here; tokens live in durable oauthAccounts state keyed by id. */
52946
+ var GoogleBrokerEntrySchema = object({
52947
+ id: string().min(1),
52948
+ name: string().min(1),
52889
52949
  clientId: string().default(""),
52890
52950
  clientSecret: string().default("").meta({ secret: true }),
52891
- /** Comma-separated Google calendar ids to expose; empty = all. */
52951
+ /**
52952
+ * @deprecated Per-broker filter; import selection lives on addon
52953
+ * `calendarIds` (Connectivity → Calendars). Kept for migrate only.
52954
+ */
52892
52955
  calendarFilter: string().default(""),
52893
52956
  /** Default From for Gmail send — usually the connected account email. */
52894
52957
  fromAddress: string().default(""),
52895
52958
  fromName: string().default("")
52896
52959
  });
52960
+ var GoogleServicesConfigSchema = object({
52961
+ brokers: array(GoogleBrokerEntrySchema).default([]),
52962
+ /**
52963
+ * Stamped calendar ids (`brokerId:googleCalendarId`) to import.
52964
+ * Empty = import none. Edited under Connectivity → Calendars.
52965
+ */
52966
+ calendarIds: array(string()).default([]),
52967
+ /** @deprecated Pre-broker singleton — cleared after migrateLegacyToBrokers. */
52968
+ clientId: string().optional(),
52969
+ /** @deprecated Pre-broker singleton — cleared after migrateLegacyToBrokers. */
52970
+ clientSecret: string().optional().meta({ secret: true }),
52971
+ /** @deprecated Pre-broker singleton — cleared after migrateLegacyToBrokers. */
52972
+ calendarFilter: string().optional(),
52973
+ /** @deprecated Pre-broker singleton — cleared after migrateLegacyToBrokers. */
52974
+ fromAddress: string().optional(),
52975
+ /** @deprecated Pre-broker singleton — cleared after migrateLegacyToBrokers. */
52976
+ fromName: string().optional()
52977
+ });
52897
52978
  var DEFAULT_GOOGLE_CONFIG = GoogleServicesConfigSchema.parse({});
52979
+ /** Flat pre-broker config (singleton). Parsed only for one-shot migration. */
52980
+ var LegacyGoogleFlatConfigSchema = GoogleServicesConfigSchema;
52898
52981
  var DEFAULT_OAUTH_STATE = {
52899
52982
  refreshToken: "",
52900
52983
  accessToken: "",
52901
52984
  accessExpiresAt: 0,
52902
52985
  accountEmail: ""
52903
52986
  };
52987
+ var GoogleOauthAccountsStateSchema = object({ accounts: array(object({
52988
+ id: string().min(1),
52989
+ refreshToken: string().default("").meta({ secret: true }),
52990
+ accessToken: string().default("").meta({ secret: true }),
52991
+ accessExpiresAt: number().default(0),
52992
+ accountEmail: string().default("")
52993
+ })).default([]) });
52994
+ var GOOGLE_BROKER_KIND = "google";
52995
+ var GOOGLE_BROKER_KIND_LABEL = "Google";
52904
52996
  var GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.readonly";
52905
52997
  var GOOGLE_GMAIL_SEND_SCOPE = "https://www.googleapis.com/auth/gmail.send";
52906
- /** Space-separated scopes requested on Connect (Calendar read + Gmail send). */
52907
- var GOOGLE_OAUTH_SCOPES = `${GOOGLE_CALENDAR_SCOPE} ${GOOGLE_GMAIL_SEND_SCOPE}`;
52998
+ /** Space-separated scopes on Connect (Calendar + Gmail + email). */
52999
+ var GOOGLE_OAUTH_SCOPES = `${GOOGLE_CALENDAR_SCOPE} ${GOOGLE_GMAIL_SEND_SCOPE} https://www.googleapis.com/auth/userinfo.email`;
52908
53000
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
52909
53001
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
52910
53002
  var GOOGLE_CALENDAR_API = "https://www.googleapis.com/calendar/v3";
52911
53003
  var GOOGLE_GMAIL_API = "https://gmail.googleapis.com/gmail/v1";
52912
53004
  var GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
53005
+ /** Lightweight Calendar probe for Test — works with calendar.readonly alone. */
53006
+ var GOOGLE_CALENDAR_LIST_URL = `${GOOGLE_CALENDAR_API}/users/me/calendarList?maxResults=1`;
52913
53007
  var OAUTH_START_PATH = `/addon/google-services/oauth/start`;
52914
53008
  var OAUTH_CALLBACK_SUFFIX = `/addon/google-services/oauth/callback`;
52915
53009
  //#endregion
53010
+ //#region src/google-broker-registry.ts
53011
+ var GoogleAddSettingsSchema = object({
53012
+ clientId: string().min(1),
53013
+ clientSecret: string().min(1).meta({ secret: true }),
53014
+ /** Selected Google calendar ids; empty = import all. */
53015
+ calendarIds: array(string()).optional(),
53016
+ /** @deprecated Prefer calendarIds — still accepted on save. */
53017
+ calendarFilter: string().optional(),
53018
+ fromAddress: string().optional(),
53019
+ fromName: string().optional()
53020
+ });
53021
+ /** Persist UI calendarIds (or legacy filter string) as comma-separated filter. */
53022
+ function calendarIdsToFilter(settings) {
53023
+ if (settings.calendarIds !== void 0) return settings.calendarIds.map((s) => s.trim()).filter((s) => s.length > 0).join(",");
53024
+ return settings.calendarFilter ?? "";
53025
+ }
53026
+ function settingsToBrokerFields(settings) {
53027
+ const parsed = GoogleAddSettingsSchema.parse(settings);
53028
+ return {
53029
+ clientId: parsed.clientId,
53030
+ clientSecret: parsed.clientSecret,
53031
+ calendarFilter: calendarIdsToFilter(parsed),
53032
+ fromAddress: parsed.fromAddress ?? "",
53033
+ fromName: parsed.fromName ?? ""
53034
+ };
53035
+ }
53036
+ function brokerEntryToSettings(entry) {
53037
+ return {
53038
+ clientId: entry.clientId,
53039
+ clientSecret: entry.clientSecret,
53040
+ calendarIds: [...parseCalendarFilter(entry.calendarFilter)],
53041
+ fromAddress: entry.fromAddress,
53042
+ fromName: entry.fromName
53043
+ };
53044
+ }
53045
+ /**
53046
+ * One-shot: flat singleton config + legacy `oauth` state → `google_001`.
53047
+ * Idempotent when `brokers` already present.
53048
+ */
53049
+ function migrateLegacyToBrokers(rawConfig, legacyOauth) {
53050
+ const loose = LegacyGoogleFlatConfigSchema.safeParse(rawConfig);
53051
+ const flat = loose.success ? loose.data : {};
53052
+ const existingBrokers = flat.brokers ?? [];
53053
+ if (existingBrokers.length > 0) return {
53054
+ brokers: existingBrokers.map((b) => GoogleBrokerEntrySchema.parse(b)),
53055
+ oauthAccounts: [],
53056
+ didMigrate: false
53057
+ };
53058
+ const hasClient = (flat.clientId ?? "").length > 0 || (flat.clientSecret ?? "").length > 0;
53059
+ const hasOauth = legacyOauth !== null && legacyOauth.refreshToken.length > 0;
53060
+ if (!hasClient && !hasOauth) return {
53061
+ brokers: [],
53062
+ oauthAccounts: [],
53063
+ didMigrate: false
53064
+ };
53065
+ const id = "google_001";
53066
+ const email = legacyOauth?.accountEmail?.trim() ?? "";
53067
+ const name = email.length > 0 ? email : "Google account";
53068
+ const entry = GoogleBrokerEntrySchema.parse({
53069
+ id,
53070
+ name,
53071
+ clientId: flat.clientId ?? "",
53072
+ clientSecret: flat.clientSecret ?? "",
53073
+ calendarFilter: flat.calendarFilter ?? "",
53074
+ fromAddress: flat.fromAddress ?? "",
53075
+ fromName: flat.fromName ?? ""
53076
+ });
53077
+ const oauthAccounts = legacyOauth !== null && (legacyOauth.refreshToken.length > 0 || legacyOauth.accessToken.length > 0) ? [{
53078
+ id,
53079
+ refreshToken: legacyOauth.refreshToken,
53080
+ accessToken: legacyOauth.accessToken,
53081
+ accessExpiresAt: legacyOauth.accessExpiresAt,
53082
+ accountEmail: legacyOauth.accountEmail
53083
+ }] : [];
53084
+ return {
53085
+ brokers: [entry],
53086
+ oauthAccounts,
53087
+ didMigrate: true
53088
+ };
53089
+ }
53090
+ function parseCalendarFilter(raw) {
53091
+ const trimmed = raw.trim();
53092
+ if (!trimmed) return /* @__PURE__ */ new Set();
53093
+ return new Set(trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0));
53094
+ }
53095
+ /** Local calendar id stamped as `brokerId:googleCalendarId`. */
53096
+ function stampCalendarId(brokerId, googleCalendarId) {
53097
+ return `${brokerId}:${googleCalendarId}`;
53098
+ }
53099
+ function parseStampedCalendarId(stamped) {
53100
+ const idx = stamped.indexOf(":");
53101
+ if (idx <= 0 || idx === stamped.length - 1) return null;
53102
+ return {
53103
+ brokerId: stamped.slice(0, idx),
53104
+ googleCalendarId: stamped.slice(idx + 1)
53105
+ };
53106
+ }
53107
+ /**
53108
+ * In-memory index over broker entries + oauth accounts. The addon owns
53109
+ * persistence; this class only allocates ids and builds BrokerInfo rows.
53110
+ */
53111
+ var GoogleBrokerRegistry = class {
53112
+ nextIdCounter = 1;
53113
+ entries = [];
53114
+ oauthById = /* @__PURE__ */ new Map();
53115
+ seedFrom(entries, accounts) {
53116
+ this.entries = entries.map((e) => GoogleBrokerEntrySchema.parse(e));
53117
+ this.oauthById = new Map(accounts.map((a) => [a.id, { ...a }]));
53118
+ this.seedCounterFromEntries(this.entries);
53119
+ }
53120
+ listEntries() {
53121
+ return this.entries;
53122
+ }
53123
+ getEntry(id) {
53124
+ return this.entries.find((e) => e.id === id) ?? null;
53125
+ }
53126
+ getOauth(id) {
53127
+ return this.oauthById.get(id) ?? null;
53128
+ }
53129
+ listOauthAccounts() {
53130
+ return [...this.oauthById.values()];
53131
+ }
53132
+ setOauth(account) {
53133
+ this.oauthById.set(account.id, { ...account });
53134
+ }
53135
+ removeOauth(id) {
53136
+ this.oauthById.delete(id);
53137
+ }
53138
+ isConnected(id) {
53139
+ const o = this.oauthById.get(id);
53140
+ return o !== void 0 && o.refreshToken.length > 0;
53141
+ }
53142
+ allocateId() {
53143
+ const id = `google_${String(this.nextIdCounter).padStart(3, "0")}`;
53144
+ this.nextIdCounter += 1;
53145
+ return id;
53146
+ }
53147
+ adoptId(id) {
53148
+ if (this.entries.some((e) => e.id === id)) throw new Error(`broker id "${id}" is already in use — adopt an id only to restore a broker that is gone`);
53149
+ const match = id.match(/^google_(\d+)$/);
53150
+ if (match?.[1] !== void 0) this.nextIdCounter = Math.max(this.nextIdCounter, Number.parseInt(match[1], 10) + 1);
53151
+ return id;
53152
+ }
53153
+ createEntry(name, settings, opts = {}) {
53154
+ const fields = settingsToBrokerFields(settings);
53155
+ const id = opts.id !== void 0 ? this.adoptId(opts.id) : this.allocateId();
53156
+ const entry = GoogleBrokerEntrySchema.parse({
53157
+ id,
53158
+ name,
53159
+ ...fields
53160
+ });
53161
+ this.entries = [...this.entries, entry];
53162
+ return entry;
53163
+ }
53164
+ updateEntry(id, settings) {
53165
+ const idx = this.entries.findIndex((e) => e.id === id);
53166
+ if (idx < 0) return null;
53167
+ const prev = this.entries[idx];
53168
+ const fields = settingsToBrokerFields(settings);
53169
+ const next = GoogleBrokerEntrySchema.parse({
53170
+ id: prev.id,
53171
+ name: prev.name,
53172
+ ...fields
53173
+ });
53174
+ this.entries = this.entries.map((e, i) => i === idx ? next : e);
53175
+ return next;
53176
+ }
53177
+ removeEntry(id) {
53178
+ const before = this.entries.length;
53179
+ this.entries = this.entries.filter((e) => e.id !== id);
53180
+ this.oauthById.delete(id);
53181
+ return this.entries.length < before;
53182
+ }
53183
+ replaceEntries(entries) {
53184
+ this.entries = entries.map((e) => GoogleBrokerEntrySchema.parse(e));
53185
+ this.seedCounterFromEntries(this.entries);
53186
+ }
53187
+ toBrokerInfo(entry, addonId) {
53188
+ const oauth = this.oauthById.get(entry.id);
53189
+ const connected = this.isConnected(entry.id);
53190
+ return {
53191
+ id: entry.id,
53192
+ addonId,
53193
+ name: entry.name,
53194
+ kind: GOOGLE_BROKER_KIND,
53195
+ status: connected ? "connected" : "disconnected",
53196
+ info: {
53197
+ accountEmail: oauth?.accountEmail ?? "",
53198
+ hasClient: entry.clientId.length > 0
53199
+ },
53200
+ lastCheckedAt: connected ? Date.now() : null,
53201
+ error: connected ? null : entry.clientId.length === 0 ? "Configure Client ID / secret, then Connect Google" : "Not connected — open Connect Google on this broker"
53202
+ };
53203
+ }
53204
+ listBrokerInfos(addonId, kind) {
53205
+ if (kind !== void 0 && kind !== "google") return [];
53206
+ return this.entries.map((e) => this.toBrokerInfo(e, addonId));
53207
+ }
53208
+ size() {
53209
+ return this.entries.length;
53210
+ }
53211
+ connectedSize() {
53212
+ return this.entries.filter((e) => this.isConnected(e.id)).length;
53213
+ }
53214
+ toConfig(calendarIds = []) {
53215
+ return {
53216
+ brokers: [...this.entries],
53217
+ calendarIds: [...calendarIds]
53218
+ };
53219
+ }
53220
+ seedCounterFromEntries(entries) {
53221
+ let maxSeen = 0;
53222
+ for (const e of entries) {
53223
+ const match = e.id.match(/^google_(\d+)$/);
53224
+ if (match?.[1] !== void 0) maxSeen = Math.max(maxSeen, Number.parseInt(match[1], 10));
53225
+ }
53226
+ this.nextIdCounter = maxSeen + 1;
53227
+ }
53228
+ };
53229
+ //#endregion
53230
+ //#region src/google-settings-copy.ts
53231
+ /**
53232
+ * Operator-facing setup copy for Connectivity → Calendars / Email.
53233
+ * Keep Connect as a same-origin relative link so the admin session cookie
53234
+ * rides along (an absolute Public URL from a LAN admin tab drops the cookie).
53235
+ * Root-relative hrefs must survive `sanitizeInfoHtml` in ui-library.
53236
+ */
53237
+ /** Enable APIs library in Google Cloud Console. */
53238
+ var GOOGLE_CONSOLE_APIS_URL = "https://console.cloud.google.com/apis/library";
53239
+ /** Create / manage OAuth Web clients. */
53240
+ var GOOGLE_CONSOLE_CREDENTIALS_URL = "https://console.cloud.google.com/apis/credentials";
53241
+ function redirectUriDisplay(publicOrigin) {
53242
+ if (publicOrigin === null || publicOrigin.length === 0) return `<public-hub>${OAUTH_CALLBACK_SUFFIX}`;
53243
+ return `${publicOrigin.replace(/\/$/, "")}${OAUTH_CALLBACK_SUFFIX}`;
53244
+ }
53245
+ function consoleLinksHtml$1() {
53246
+ return [
53247
+ `<a href="${GOOGLE_CONSOLE_APIS_URL}">API library</a>`,
53248
+ ` · `,
53249
+ `<a href="${GOOGLE_CONSOLE_CREDENTIALS_URL}">Credentials</a>`
53250
+ ].join("");
53251
+ }
53252
+ function introHtml(cap, publicOrigin) {
53253
+ const redirect = escapeHtml$2(redirectUriDisplay(publicOrigin));
53254
+ const console = consoleLinksHtml$1();
53255
+ if (cap === "smtp-provider") return [
53256
+ "<ol>",
53257
+ `<li>Open Google Cloud Console (${console}) and enable the <strong>Gmail API</strong> (and Calendar API if you also use Calendars).</li>`,
53258
+ "<li>Under Credentials, create an OAuth client of type <strong>Web application</strong>.</li>",
53259
+ `<li>Add this authorized redirect URI (Network → <strong>Public endpoint</strong>, not Internal): <code>${redirect}</code></li>`,
53260
+ "<li>Paste Client ID and Client secret below, then <strong>Save</strong>.</li>",
53261
+ `<li>Open <a href="${OAUTH_START_PATH}">Connect Google</a> while signed in as admin (same browser tab as this hub).</li>`,
53262
+ "<li>Set From address (connected account or a verified send-as). Turn this integration off with the page <strong>Enable</strong> toggle when you do not want Gmail send — Calendar can stay connected.</li>",
53263
+ "</ol>"
53264
+ ].join("");
53265
+ if (cap === "calendar-source") return [
53266
+ "<ol>",
53267
+ `<li>Open Google Cloud Console (${console}) and enable the <strong>Google Calendar API</strong> (and Gmail API if you also use Email).</li>`,
53268
+ "<li>Under Credentials, create an OAuth client of type <strong>Web application</strong>.</li>",
53269
+ `<li>Add this authorized redirect URI (Network → <strong>Public endpoint</strong>, not Internal): <code>${redirect}</code></li>`,
53270
+ "<li>Paste Client ID and Client secret below, then <strong>Save</strong>.</li>",
53271
+ `<li>Open <a href="${OAUTH_START_PATH}">Connect Google</a> while signed in as admin (same browser tab as this hub).</li>`,
53272
+ "<li>Optionally restrict which calendars are exposed with the filter below.</li>",
53273
+ "</ol>"
53274
+ ].join("");
53275
+ return [
53276
+ "<ol>",
53277
+ `<li>Open Google Cloud Console (${console}) and enable <strong>Google Calendar API</strong> and <strong>Gmail API</strong>.</li>`,
53278
+ "<li>Create a <strong>Web application</strong> OAuth client under Credentials.</li>",
53279
+ `<li>Register redirect URI: <code>${redirect}</code> (Network → Public endpoint).</li>`,
53280
+ `<li>Paste Client ID / secret, Save, then <a href="${OAUTH_START_PATH}">Connect Google</a>.</li>`,
53281
+ "<li>Calendars and Email share one Google account; use each page’s <strong>Enable</strong> toggle to turn a capability off without revoking OAuth.</li>",
53282
+ "</ol>"
53283
+ ].join("");
53284
+ }
53285
+ function connectHtml(cap, publicOrigin) {
53286
+ const redirect = escapeHtml$2(redirectUriDisplay(publicOrigin));
53287
+ if (cap === "smtp-provider") return [
53288
+ `After Save, open <a href="${OAUTH_START_PATH}">Connect Google</a> in this hub (admin session required). `,
53289
+ "Connect requests <code>gmail.send</code> (and Calendar read for the Calendars page). ",
53290
+ `Google must list exactly: <code>${redirect}</code>. `,
53291
+ "If an older Connect predates Gmail send, revoke CamStack in Google Account → Security and Connect again. ",
53292
+ `Console: ${consoleLinksHtml$1()}.`
53293
+ ].join("");
53294
+ if (cap === "calendar-source") return [
53295
+ `After Save, open <a href="${OAUTH_START_PATH}">Connect Google</a> in this hub (admin session required). `,
53296
+ "Connect requests <code>calendar.readonly</code> (and Gmail send for the Email page). ",
53297
+ `Google must list exactly: <code>${redirect}</code>. `,
53298
+ `Console: ${consoleLinksHtml$1()}.`
53299
+ ].join("");
53300
+ return [
53301
+ `After Save, open <a href="${OAUTH_START_PATH}">Connect Google</a>. `,
53302
+ `Redirect URI registered in Google Cloud must be <code>${redirect}</code>. `,
53303
+ `Console: ${consoleLinksHtml$1()}.`
53304
+ ].join("");
53305
+ }
53306
+ function escapeHtml$2(s) {
53307
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
53308
+ }
53309
+ //#endregion
53310
+ //#region src/google-broker-settings-schema.ts
53311
+ /**
53312
+ * Per-kind broker creation/edit form for kind=`google`.
53313
+ * Account linking (Sign in) is driven by `listProviders.interactiveConnectPath`
53314
+ * in BrokerManager — not a hard-coded Connect link in this form.
53315
+ */
53316
+ function consoleLinksHtml() {
53317
+ return [
53318
+ `<a href="${GOOGLE_CONSOLE_APIS_URL}">API library</a>`,
53319
+ ` · `,
53320
+ `<a href="${GOOGLE_CONSOLE_CREDENTIALS_URL}">Credentials</a>`
53321
+ ].join("");
53322
+ }
53323
+ /** Setup steps shown on Add / Edit — includes Audience / Test users. */
53324
+ function googleBrokerSetupHtml(publicOrigin) {
53325
+ const redirect = escapeHtml$1(redirectUriDisplay(publicOrigin));
53326
+ return [
53327
+ "<ol>",
53328
+ `<li>Open Google Cloud Console (${consoleLinksHtml()}). Create a <strong>new project</strong> (or pick an existing one) from the project picker at the top.</li>`,
53329
+ "<li>In that project, enable <strong>Google Calendar API</strong> and/or <strong>Gmail API</strong> (API library).</li>",
53330
+ "<li>Open <strong>Google Auth Platform → Audience</strong> (or APIs &amp; Services → OAuth consent screen → <strong>Audience</strong>). User type <strong>External</strong> + publishing status <strong>Testing</strong> is fine for a personal hub. Under <strong>Test users</strong>, add the Google account(s) that will Sign in — otherwise Google returns <code>403: access_denied</code>. (If you only see Branding / Data Access, open the <strong>Audience</strong> tab in the left sidebar of Google Auth Platform.) Publishing / verification is optional for a private hub.</li>",
53331
+ "<li>Under Credentials, create an OAuth client of type <strong>Web application</strong>.</li>",
53332
+ `<li>Add this authorized redirect URI (Network → <strong>Public endpoint</strong>, not Internal): <code>${redirect}</code></li>`,
53333
+ "<li>Paste Client ID and Client secret below. <strong>Test connection</strong> checks those with Google (not the account yet).</li>",
53334
+ "<li>Add the broker — the hub then opens Google sign-in in a popup to link the account (Calendar + Gmail). Use <strong>Sign in</strong> on the row if you skip or cancel.</li>",
53335
+ "</ol>"
53336
+ ].join("");
53337
+ }
53338
+ function buildGoogleBrokerSettingsSchema(publicOrigin) {
53339
+ return { sections: [
53340
+ {
53341
+ id: "setup-intro",
53342
+ title: "Setup",
53343
+ description: "Google Cloud Console — register the hub redirect before Add / Sign in.",
53344
+ columns: 1,
53345
+ fields: [{
53346
+ type: "info",
53347
+ key: "_setupSteps",
53348
+ label: "Steps",
53349
+ format: "html",
53350
+ content: googleBrokerSetupHtml(publicOrigin)
53351
+ }]
53352
+ },
53353
+ {
53354
+ id: "google-oauth-client",
53355
+ title: "Google OAuth client",
53356
+ description: "Web application client from Google Cloud Console. The same Cloud project can be reused across Google brokers.",
53357
+ columns: 1,
53358
+ fields: [{
53359
+ type: "text",
53360
+ key: "clientId",
53361
+ label: "Client ID"
53362
+ }, {
53363
+ type: "password",
53364
+ key: "clientSecret",
53365
+ label: "Client secret",
53366
+ showToggle: true
53367
+ }]
53368
+ },
53369
+ {
53370
+ id: "gmail-send",
53371
+ title: "Gmail send",
53372
+ description: "From must be this connected account or a verified send-as. Disable Email on Connectivity to turn Gmail off without removing the broker.",
53373
+ columns: 1,
53374
+ fields: [{
53375
+ type: "text",
53376
+ key: "fromAddress",
53377
+ label: "From address",
53378
+ description: "Defaults to the connected Google account email when empty."
53379
+ }, {
53380
+ type: "text",
53381
+ key: "fromName",
53382
+ label: "From display name (optional)"
53383
+ }]
53384
+ }
53385
+ ] };
53386
+ }
53387
+ /** Connectivity Calendars / Email pages — calendar import picker lives here (not on broker Add). */
53388
+ function buildGoogleFeatureSettingsSchema(cap, publicOrigin) {
53389
+ const redirect = escapeHtml$1(redirectUriDisplay(publicOrigin));
53390
+ if (cap === "calendar-source") return { sections: [{
53391
+ id: "setup-intro",
53392
+ title: "Google brokers",
53393
+ columns: 1,
53394
+ fields: [{
53395
+ type: "info",
53396
+ key: "_setupSteps",
53397
+ label: "Setup",
53398
+ format: "html",
53399
+ content: [
53400
+ "<p>Link a Google account under Connectivity → <strong>Brokers</strong> ",
53401
+ "(Client ID / secret + Sign in). Then pick calendars below. ",
53402
+ `OAuth redirect URI: <code>${redirect}</code>.</p>`
53403
+ ].join("")
53404
+ }]
53405
+ }, {
53406
+ id: "google-calendars-import",
53407
+ title: "Calendars to import",
53408
+ description: "Nothing selected = no calendars imported. Options appear after at least one Google broker is signed in.",
53409
+ columns: 1,
53410
+ fields: [{
53411
+ type: "multiselect",
53412
+ key: "calendarIds",
53413
+ label: "Calendars",
53414
+ options: [],
53415
+ optionsFrom: "_calendarCatalog",
53416
+ description: "Ids are stamped as brokerId:googleCalendarId across every Google broker."
53417
+ }]
53418
+ }] };
53419
+ return { sections: [{
53420
+ id: "setup-intro",
53421
+ title: cap === "smtp-provider" ? "Gmail send uses Google brokers" : "Google brokers",
53422
+ columns: 1,
53423
+ fields: [{
53424
+ type: "info",
53425
+ key: "_setupSteps",
53426
+ label: "Setup",
53427
+ format: "html",
53428
+ content: cap === "smtp-provider" ? [
53429
+ "<p>Add or edit a <strong>Google</strong> broker under Connectivity → <strong>Brokers</strong> ",
53430
+ "(Client ID / secret + Sign in). From address and send-as live on that broker. ",
53431
+ `OAuth redirect URI: <code>${redirect}</code>.</p>`
53432
+ ].join("") : [
53433
+ "<p>Add or edit a <strong>Google</strong> broker under Connectivity → <strong>Brokers</strong> ",
53434
+ "(Client ID / secret + Sign in). ",
53435
+ `OAuth redirect URI: <code>${redirect}</code>.</p>`
53436
+ ].join("")
53437
+ }]
53438
+ }] };
53439
+ }
53440
+ function escapeHtml$1(s) {
53441
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
53442
+ }
53443
+ //#endregion
52916
53444
  //#region src/google-calendar-source.ts
52917
53445
  function parseGoogleInstant(value) {
52918
53446
  const ms = Date.parse(value);
@@ -52975,39 +53503,75 @@ function createGoogleCalendarApi(getAccessToken, http = { fetch }) {
52975
53503
  function createGoogleCalendarSource(deps) {
52976
53504
  return {
52977
53505
  listCalendars: async () => {
52978
- if (!deps.connected()) return [];
52979
- const filter = deps.filterIds();
52980
- return (await deps.api.listCalendars()).filter((c) => filter.size === 0 ? true : filter.has(c.id)).map((c) => ({
52981
- id: c.id,
52982
- name: c.summary,
52983
- source: "addon",
52984
- enabled: true
52985
- }));
53506
+ const selected = deps.selectedCalendarIds();
53507
+ if (selected.size === 0) return [];
53508
+ const out = [];
53509
+ for (const account of deps.accounts()) {
53510
+ if (!account.connected) continue;
53511
+ try {
53512
+ const list = await account.api.listCalendars();
53513
+ for (const c of list) {
53514
+ const stamped = stampCalendarId(account.brokerId, c.id);
53515
+ if (!selected.has(stamped)) continue;
53516
+ out.push({
53517
+ id: stamped,
53518
+ name: c.summary,
53519
+ source: "addon",
53520
+ enabled: true
53521
+ });
53522
+ }
53523
+ } catch (err) {
53524
+ deps.logger.warn("google calendar listCalendars failed", { meta: {
53525
+ brokerId: account.brokerId,
53526
+ error: err instanceof Error ? err.message : String(err)
53527
+ } });
53528
+ }
53529
+ }
53530
+ return out;
52986
53531
  },
52987
53532
  listEvents: async (input) => {
52988
- if (!deps.connected()) return [];
52989
- const filter = deps.filterIds();
52990
- let calendarIds = input.calendarIds;
52991
- if (calendarIds === void 0) calendarIds = (await deps.api.listCalendars()).filter((c) => filter.size === 0 ? true : filter.has(c.id)).map((c) => c.id);
52992
- else if (filter.size > 0) calendarIds = calendarIds.filter((id) => filter.has(id));
53533
+ const selected = deps.selectedCalendarIds();
53534
+ if (selected.size === 0) return [];
52993
53535
  const timeMin = new Date(input.startMs).toISOString();
52994
53536
  const timeMax = new Date(input.endMs).toISOString();
53537
+ const accounts = deps.accounts().filter((a) => a.connected);
53538
+ const byBroker = new Map(accounts.map((a) => [a.brokerId, a]));
53539
+ const requestIds = input.calendarIds !== void 0 && input.calendarIds.length > 0 ? input.calendarIds : [...selected];
53540
+ /** brokerId → google calendar ids to fetch */
53541
+ const wanted = /* @__PURE__ */ new Map();
53542
+ for (const stamped of requestIds) {
53543
+ if (!selected.has(stamped)) continue;
53544
+ const parsed = parseStampedCalendarId(stamped);
53545
+ if (parsed === null) continue;
53546
+ if (!byBroker.has(parsed.brokerId)) continue;
53547
+ const list = wanted.get(parsed.brokerId) ?? [];
53548
+ list.push(parsed.googleCalendarId);
53549
+ wanted.set(parsed.brokerId, list);
53550
+ }
52995
53551
  const out = [];
52996
- for (const calendarId of calendarIds) try {
52997
- const events = await deps.api.listEvents({
52998
- calendarId,
52999
- timeMin,
53000
- timeMax
53001
- });
53002
- for (const ev of events) {
53003
- const mapped = mapGoogleEvent(calendarId, ev);
53004
- if (mapped) out.push(mapped);
53552
+ for (const [brokerId, googleIds] of wanted) {
53553
+ const account = byBroker.get(brokerId);
53554
+ if (account === void 0) continue;
53555
+ for (const googleCalendarId of googleIds) {
53556
+ const stampedId = stampCalendarId(brokerId, googleCalendarId);
53557
+ try {
53558
+ const events = await account.api.listEvents({
53559
+ calendarId: googleCalendarId,
53560
+ timeMin,
53561
+ timeMax
53562
+ });
53563
+ for (const ev of events) {
53564
+ const mapped = mapGoogleEvent(stampedId, ev);
53565
+ if (mapped) out.push(mapped);
53566
+ }
53567
+ } catch (err) {
53568
+ deps.logger.warn("google calendar listEvents failed", { meta: {
53569
+ brokerId,
53570
+ calendarId: googleCalendarId,
53571
+ error: err instanceof Error ? err.message : String(err)
53572
+ } });
53573
+ }
53005
53574
  }
53006
- } catch (err) {
53007
- deps.logger.warn("google calendar listEvents failed", { meta: {
53008
- calendarId,
53009
- error: err instanceof Error ? err.message : String(err)
53010
- } });
53011
53575
  }
53012
53576
  return out;
53013
53577
  }
@@ -53025,12 +53589,26 @@ function encodeHeaderWord(value) {
53025
53589
  function formatAddressList(addrs) {
53026
53590
  return (Array.isArray(addrs) ? addrs : [addrs]).join(", ");
53027
53591
  }
53028
- function buildFromHeader(cfg, accountEmail) {
53029
- const addr = cfg.fromAddress.trim() || accountEmail;
53592
+ function buildFromHeader(account) {
53593
+ const addr = account.fromAddress.trim() || account.accountEmail;
53030
53594
  if (!addr) return "";
53031
- if (cfg.fromName.trim()) return `${encodeHeaderWord(cfg.fromName.trim())} <${addr}>`;
53595
+ if (account.fromName.trim()) return `${encodeHeaderWord(account.fromName.trim())} <${addr}>`;
53032
53596
  return addr;
53033
53597
  }
53598
+ function extractEmail(fromHeader) {
53599
+ const angled = /<([^>]+)>/.exec(fromHeader);
53600
+ if (angled?.[1] !== void 0) return angled[1].trim().toLowerCase();
53601
+ return fromHeader.trim().toLowerCase();
53602
+ }
53603
+ /** Pick account: match From to accountEmail/fromAddress; else first connected. */
53604
+ function selectGmailAccount(accounts, fromHeader) {
53605
+ const connected = accounts.filter((a) => a.connected);
53606
+ if (connected.length === 0) return null;
53607
+ if (fromHeader === void 0 || fromHeader.trim().length === 0) return connected[0] ?? null;
53608
+ const want = extractEmail(fromHeader);
53609
+ for (const a of connected) if ([a.accountEmail, a.fromAddress].map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0).includes(want)) return a;
53610
+ return connected[0] ?? null;
53611
+ }
53034
53612
  /** Build a minimal multipart/alternative-capable plain (and optional HTML) message. */
53035
53613
  function buildRawMime(input) {
53036
53614
  const lines = [`From: ${input.from}`, `To: ${formatAddressList(input.to)}`];
@@ -53067,17 +53645,17 @@ function createGmailSmtpProvider(deps) {
53067
53645
  let lastVerifiedAt;
53068
53646
  let lastError;
53069
53647
  const status = () => {
53070
- const cfg = deps.getConfig();
53071
- const email = deps.getAccountEmail() || cfg.fromAddress.trim() || "gmail";
53072
- if (!deps.connected()) return {
53648
+ const connected = deps.accounts().filter((a) => a.connected);
53649
+ const host = connected[0]?.accountEmail || connected[0]?.fromAddress.trim() || (connected.length > 0 ? "gmail" : "gmail");
53650
+ if (connected.length === 0) return {
53073
53651
  ready: false,
53074
- host: email,
53075
- error: "Google account is not connected — open Connect Google.",
53652
+ host,
53653
+ error: "No Google broker connected — add one under Connectivity → Brokers and Connect.",
53076
53654
  lastVerifiedAt
53077
53655
  };
53078
53656
  return {
53079
53657
  ready: lastError === void 0 && lastVerifiedAt !== void 0,
53080
- host: email,
53658
+ host: connected.length === 1 ? host : `${host} (+${connected.length - 1})`,
53081
53659
  error: lastError,
53082
53660
  lastVerifiedAt
53083
53661
  };
@@ -53085,12 +53663,13 @@ function createGmailSmtpProvider(deps) {
53085
53663
  return {
53086
53664
  getStatus: async () => status(),
53087
53665
  verify: async () => {
53088
- if (!deps.connected()) {
53089
- lastError = "Google account is not connected.";
53666
+ const account = selectGmailAccount(deps.accounts(), void 0);
53667
+ if (account === null) {
53668
+ lastError = "No Google broker connected.";
53090
53669
  return status();
53091
53670
  }
53092
53671
  try {
53093
- const token = await deps.getAccessToken();
53672
+ const token = await account.getAccessToken();
53094
53673
  const res = await http(`${GOOGLE_GMAIL_API}/users/me/profile`, { headers: { Authorization: `Bearer ${token}` } });
53095
53674
  if (!res.ok) {
53096
53675
  const text = await res.text();
@@ -53106,10 +53685,10 @@ function createGmailSmtpProvider(deps) {
53106
53685
  }
53107
53686
  },
53108
53687
  sendEmail: async (input) => {
53109
- const cfg = deps.getConfig();
53110
- if (!deps.connected()) throw new Error("Google account is not connected — open Connect Google.");
53111
- const from = input.from?.trim() || buildFromHeader(cfg, deps.getAccountEmail());
53112
- if (!from) throw new Error("Set From address in Google Services settings (or connect an account).");
53688
+ const account = selectGmailAccount(deps.accounts(), input.from);
53689
+ if (account === null) throw new Error("No Google broker connected — add one under Connectivity → Brokers and Connect.");
53690
+ const from = input.from?.trim() || buildFromHeader(account);
53691
+ if (!from) throw new Error("Set From address on the Google broker (or Connect an account).");
53113
53692
  const raw = buildRawMime({
53114
53693
  from,
53115
53694
  to: input.to,
@@ -53120,7 +53699,7 @@ function createGmailSmtpProvider(deps) {
53120
53699
  text: input.text,
53121
53700
  html: input.html
53122
53701
  });
53123
- const token = await deps.getAccessToken();
53702
+ const token = await account.getAccessToken();
53124
53703
  const res = await http(`${GOOGLE_GMAIL_API}/users/me/messages/send`, {
53125
53704
  method: "POST",
53126
53705
  headers: {
@@ -53211,85 +53790,59 @@ function needsRefresh(state, nowMs = Date.now()) {
53211
53790
  if (!state.accessToken) return true;
53212
53791
  return state.accessExpiresAt <= nowMs;
53213
53792
  }
53214
- //#endregion
53215
- //#region src/google-settings-copy.ts
53216
53793
  /**
53217
- * Operator-facing setup copy for Connectivity → Calendars / Email.
53218
- * Keep Connect as a same-origin relative link so the admin session cookie
53219
- * rides along (an absolute Public URL from a LAN admin tab drops the cookie).
53220
- * Root-relative hrefs must survive `sanitizeInfoHtml` in ui-library.
53794
+ * Probe Client ID / secret against Google's token endpoint with a throwaway
53795
+ * authorization code. `invalid_client` means bad credentials; `invalid_grant`
53796
+ * / `redirect_uri_mismatch` mean Google accepted the client (code was fake).
53221
53797
  */
53222
- /** Enable APIs library in Google Cloud Console. */
53223
- var GOOGLE_CONSOLE_APIS_URL = "https://console.cloud.google.com/apis/library";
53224
- /** Create / manage OAuth Web clients. */
53225
- var GOOGLE_CONSOLE_CREDENTIALS_URL = "https://console.cloud.google.com/apis/credentials";
53226
- function redirectUriDisplay(publicOrigin) {
53227
- if (publicOrigin === null || publicOrigin.length === 0) return `<public-hub>${OAUTH_CALLBACK_SUFFIX}`;
53228
- return `${publicOrigin.replace(/\/$/, "")}${OAUTH_CALLBACK_SUFFIX}`;
53229
- }
53230
- function consoleLinksHtml() {
53231
- return [
53232
- `<a href="${GOOGLE_CONSOLE_APIS_URL}">API library</a>`,
53233
- ` · `,
53234
- `<a href="${GOOGLE_CONSOLE_CREDENTIALS_URL}">Credentials</a>`
53235
- ].join("");
53236
- }
53237
- function introHtml(cap, publicOrigin) {
53238
- const redirect = escapeHtml$1(redirectUriDisplay(publicOrigin));
53239
- const console = consoleLinksHtml();
53240
- if (cap === "smtp-provider") return [
53241
- "<ol>",
53242
- `<li>Open Google Cloud Console (${console}) and enable the <strong>Gmail API</strong> (and Calendar API if you also use Calendars).</li>`,
53243
- "<li>Under Credentials, create an OAuth client of type <strong>Web application</strong>.</li>",
53244
- `<li>Add this authorized redirect URI (Network → <strong>Public endpoint</strong>, not Internal): <code>${redirect}</code></li>`,
53245
- "<li>Paste Client ID and Client secret below, then <strong>Save</strong>.</li>",
53246
- `<li>Open <a href="${OAUTH_START_PATH}">Connect Google</a> while signed in as admin (same browser tab as this hub).</li>`,
53247
- "<li>Set From address (connected account or a verified send-as). Turn this integration off with the page <strong>Enable</strong> toggle when you do not want Gmail send — Calendar can stay connected.</li>",
53248
- "</ol>"
53249
- ].join("");
53250
- if (cap === "calendar-source") return [
53251
- "<ol>",
53252
- `<li>Open Google Cloud Console (${console}) and enable the <strong>Google Calendar API</strong> (and Gmail API if you also use Email).</li>`,
53253
- "<li>Under Credentials, create an OAuth client of type <strong>Web application</strong>.</li>",
53254
- `<li>Add this authorized redirect URI (Network → <strong>Public endpoint</strong>, not Internal): <code>${redirect}</code></li>`,
53255
- "<li>Paste Client ID and Client secret below, then <strong>Save</strong>.</li>",
53256
- `<li>Open <a href="${OAUTH_START_PATH}">Connect Google</a> while signed in as admin (same browser tab as this hub).</li>`,
53257
- "<li>Optionally restrict which calendars are exposed with the filter below.</li>",
53258
- "</ol>"
53259
- ].join("");
53260
- return [
53261
- "<ol>",
53262
- `<li>Open Google Cloud Console (${console}) and enable <strong>Google Calendar API</strong> and <strong>Gmail API</strong>.</li>`,
53263
- "<li>Create a <strong>Web application</strong> OAuth client under Credentials.</li>",
53264
- `<li>Register redirect URI: <code>${redirect}</code> (Network → Public endpoint).</li>`,
53265
- `<li>Paste Client ID / secret, Save, then <a href="${OAUTH_START_PATH}">Connect Google</a>.</li>`,
53266
- "<li>Calendars and Email share one Google account; use each page’s <strong>Enable</strong> toggle to turn a capability off without revoking OAuth.</li>",
53267
- "</ol>"
53268
- ].join("");
53269
- }
53270
- function connectHtml(cap, publicOrigin) {
53271
- const redirect = escapeHtml$1(redirectUriDisplay(publicOrigin));
53272
- if (cap === "smtp-provider") return [
53273
- `After Save, open <a href="${OAUTH_START_PATH}">Connect Google</a> in this hub (admin session required). `,
53274
- "Connect requests <code>gmail.send</code> (and Calendar read for the Calendars page). ",
53275
- `Google must list exactly: <code>${redirect}</code>. `,
53276
- "If an older Connect predates Gmail send, revoke CamStack in Google Account → Security and Connect again. ",
53277
- `Console: ${consoleLinksHtml()}.`
53278
- ].join("");
53279
- if (cap === "calendar-source") return [
53280
- `After Save, open <a href="${OAUTH_START_PATH}">Connect Google</a> in this hub (admin session required). `,
53281
- "Connect requests <code>calendar.readonly</code> (and Gmail send for the Email page). ",
53282
- `Google must list exactly: <code>${redirect}</code>. `,
53283
- `Console: ${consoleLinksHtml()}.`
53284
- ].join("");
53285
- return [
53286
- `After Save, open <a href="${OAUTH_START_PATH}">Connect Google</a>. `,
53287
- `Redirect URI registered in Google Cloud must be <code>${redirect}</code>. `,
53288
- `Console: ${consoleLinksHtml()}.`
53289
- ].join("");
53290
- }
53291
- function escapeHtml$1(s) {
53292
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
53798
+ async function probeGoogleOauthClient(cfg, http = { fetch }) {
53799
+ const started = Date.now();
53800
+ const body = new URLSearchParams({
53801
+ code: "camstack-probe-invalid",
53802
+ client_id: cfg.clientId,
53803
+ client_secret: cfg.clientSecret,
53804
+ redirect_uri: cfg.redirectUri,
53805
+ grant_type: "authorization_code"
53806
+ });
53807
+ let res;
53808
+ try {
53809
+ res = await http.fetch(GOOGLE_TOKEN_URL, {
53810
+ method: "POST",
53811
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
53812
+ body
53813
+ });
53814
+ } catch (err) {
53815
+ return {
53816
+ ok: false,
53817
+ error: err instanceof Error ? err.message : String(err)
53818
+ };
53819
+ }
53820
+ const text = await res.text();
53821
+ let errorCode = "";
53822
+ try {
53823
+ const json = JSON.parse(text);
53824
+ if (typeof json.error === "string") errorCode = json.error;
53825
+ } catch {}
53826
+ if (errorCode === "invalid_client") return {
53827
+ ok: false,
53828
+ error: "Invalid Client ID or Client secret (Google rejected the client)"
53829
+ };
53830
+ if (errorCode === "unauthorized_client") return {
53831
+ ok: false,
53832
+ error: "This OAuth client is not allowed to use the authorization-code grant"
53833
+ };
53834
+ if (errorCode === "invalid_grant" || errorCode === "redirect_uri_mismatch" || errorCode === "invalid_request") return {
53835
+ ok: true,
53836
+ latencyMs: Math.max(0, Date.now() - started)
53837
+ };
53838
+ if (res.ok) return {
53839
+ ok: true,
53840
+ latencyMs: Math.max(0, Date.now() - started)
53841
+ };
53842
+ return {
53843
+ ok: false,
53844
+ error: errorCode.length > 0 ? `Google token probe failed (${errorCode}): ${text.slice(0, 200)}` : `Google token probe failed (${res.status}): ${text.slice(0, 200)}`
53845
+ };
53293
53846
  }
53294
53847
  //#endregion
53295
53848
  //#region src/oauth-redirect-origin.ts
@@ -53304,65 +53857,125 @@ function pickOauthRedirectOrigin(book) {
53304
53857
  //#endregion
53305
53858
  //#region src/google-services.addon.ts
53306
53859
  /**
53307
- * Google Services — hub-only calendar-source + Gmail smtp-provider (same OAuth2).
53860
+ * Google Services — hub-only broker kind `google` + calendar-source + Gmail smtp.
53861
+ * One OAuth account = one broker entry; feature caps aggregate connected brokers.
53308
53862
  */
53309
53863
  var ADDON_ID = "google-services";
53310
53864
  var CALLBACK_PATH = "/oauth/callback";
53311
53865
  var START_PATH = "/oauth/start";
53312
- var OauthStateSchema = object({
53313
- refreshToken: string().default(""),
53314
- accessToken: string().default(""),
53866
+ var DISCONNECT_PATH = "/oauth/disconnect";
53867
+ var LegacyOauthStateSchema = object({
53868
+ refreshToken: string().default("").meta({ secret: true }),
53869
+ accessToken: string().default("").meta({ secret: true }),
53315
53870
  accessExpiresAt: number().default(0),
53316
53871
  accountEmail: string().default("")
53317
53872
  });
53318
53873
  var GoogleServicesAddon = class extends BaseAddon {
53319
- secretStatePaths = ["oauth.refreshToken", "oauth.accessToken"];
53320
- oauthHandle = null;
53321
- oauth = { ...DEFAULT_OAUTH_STATE };
53322
- /** CSRF state → issuedAt ms */
53874
+ secretStatePaths = [
53875
+ "oauthAccounts.accounts[id].refreshToken",
53876
+ "oauthAccounts.accounts[id].accessToken",
53877
+ "oauth.refreshToken",
53878
+ "oauth.accessToken"
53879
+ ];
53880
+ oauthAccountsHandle = null;
53881
+ registry = new GoogleBrokerRegistry();
53882
+ /** CSRF state → { brokerId, popup, issuedAt } — popup mirrors OIDC D794. */
53323
53883
  pendingStates = /* @__PURE__ */ new Map();
53324
- /** Cached Public-endpoint origin for settings copy (redirect URI display). */
53325
53884
  publicOrigin = null;
53326
53885
  constructor() {
53327
53886
  super({ ...DEFAULT_GOOGLE_CONFIG });
53328
53887
  }
53329
53888
  async onInitialize() {
53330
- this.oauthHandle = this.state("oauth", OauthStateSchema, { ...DEFAULT_OAUTH_STATE });
53331
- this.oauth = await this.oauthHandle.get();
53889
+ this.oauthAccountsHandle = this.state("oauthAccounts", GoogleOauthAccountsStateSchema, { accounts: [] });
53890
+ let oauthAccounts = await this.oauthAccountsHandle.get();
53891
+ const legacyHandle = this.state("oauth", LegacyOauthStateSchema, { ...DEFAULT_OAUTH_STATE });
53892
+ const legacyOauth = await legacyHandle.get();
53893
+ const migrated = migrateLegacyToBrokers(this.config, legacyOauth);
53894
+ if (migrated.didMigrate) {
53895
+ await this.updateGlobalSettings({
53896
+ brokers: [...migrated.brokers],
53897
+ clientId: void 0,
53898
+ clientSecret: void 0,
53899
+ calendarFilter: void 0,
53900
+ fromAddress: void 0,
53901
+ fromName: void 0
53902
+ });
53903
+ if (migrated.oauthAccounts.length > 0) {
53904
+ oauthAccounts = { accounts: [...migrated.oauthAccounts] };
53905
+ await this.oauthAccountsHandle.set(oauthAccounts);
53906
+ }
53907
+ await legacyHandle.set({ ...DEFAULT_OAUTH_STATE });
53908
+ this.ctx.logger.info("google-services: migrated singleton OAuth to broker google_001", { meta: {
53909
+ brokerId: "google_001",
53910
+ accountEmail: legacyOauth.accountEmail || void 0
53911
+ } });
53912
+ } else if (oauthAccounts.accounts.length === 0 && legacyOauth.refreshToken.length > 0 && this.config.brokers.length > 0) {
53913
+ oauthAccounts = { accounts: [{
53914
+ id: this.config.brokers[0].id,
53915
+ refreshToken: legacyOauth.refreshToken,
53916
+ accessToken: legacyOauth.accessToken,
53917
+ accessExpiresAt: legacyOauth.accessExpiresAt,
53918
+ accountEmail: legacyOauth.accountEmail
53919
+ }] };
53920
+ await this.oauthAccountsHandle.set(oauthAccounts);
53921
+ await legacyHandle.set({ ...DEFAULT_OAUTH_STATE });
53922
+ }
53923
+ this.registry.seedFrom(this.config.brokers, oauthAccounts.accounts);
53332
53924
  await this.refreshPublicOrigin();
53333
- const api = createGoogleCalendarApi(async () => this.ensureAccessToken());
53334
53925
  const calendarProvider = createGoogleCalendarSource({
53335
53926
  logger: this.ctx.logger,
53336
- api,
53337
- connected: () => this.oauth.refreshToken.length > 0,
53338
- filterIds: () => this.parseFilter()
53927
+ accounts: () => this.registry.listEntries().map((entry) => ({
53928
+ brokerId: entry.id,
53929
+ connected: this.registry.isConnected(entry.id),
53930
+ api: createGoogleCalendarApi(() => this.ensureAccessToken(entry.id))
53931
+ })),
53932
+ selectedCalendarIds: () => new Set(this.config.calendarIds ?? [])
53339
53933
  });
53340
- const smtpProvider = createGmailSmtpProvider({
53341
- getAccessToken: () => this.ensureAccessToken(),
53342
- getConfig: () => this.config,
53343
- getAccountEmail: () => this.oauth.accountEmail,
53344
- connected: () => this.oauth.refreshToken.length > 0
53345
- });
53346
- const routeProvider = buildAddonRouteProvider(ADDON_ID, [{
53347
- method: "GET",
53348
- path: START_PATH,
53349
- access: "admin",
53350
- description: "Begin Google OAuth (Calendar + Gmail send)",
53351
- handler: async (_req, reply) => this.handleStart(reply)
53352
- }, {
53353
- method: "GET",
53354
- path: CALLBACK_PATH,
53355
- access: "public",
53356
- rateLimit: "auth",
53357
- description: "Google OAuth callback",
53358
- handler: async (req, reply) => this.handleCallback(req, reply)
53359
- }]);
53360
- this.ctx.logger.info("Initialized — google calendar-source + gmail smtp", { meta: {
53361
- connected: this.oauth.refreshToken.length > 0,
53362
- accountEmail: this.oauth.accountEmail || void 0,
53934
+ const smtpProvider = createGmailSmtpProvider({ accounts: () => this.registry.listEntries().map((entry) => {
53935
+ const oauth = this.registry.getOauth(entry.id);
53936
+ return {
53937
+ brokerId: entry.id,
53938
+ accountEmail: oauth?.accountEmail ?? "",
53939
+ fromAddress: entry.fromAddress,
53940
+ fromName: entry.fromName,
53941
+ connected: this.registry.isConnected(entry.id),
53942
+ getAccessToken: () => this.ensureAccessToken(entry.id)
53943
+ };
53944
+ }) });
53945
+ const routeProvider = buildAddonRouteProvider(ADDON_ID, [
53946
+ {
53947
+ method: "GET",
53948
+ path: START_PATH,
53949
+ access: "admin",
53950
+ description: "Begin Google OAuth for a broker (Calendar + Gmail send)",
53951
+ handler: async (req, reply) => this.handleStart(req, reply)
53952
+ },
53953
+ {
53954
+ method: "GET",
53955
+ path: CALLBACK_PATH,
53956
+ access: "public",
53957
+ rateLimit: "auth",
53958
+ description: "Google OAuth callback",
53959
+ handler: async (req, reply) => this.handleCallback(req, reply)
53960
+ },
53961
+ {
53962
+ method: "GET",
53963
+ path: DISCONNECT_PATH,
53964
+ access: "admin",
53965
+ description: "Clear Google OAuth tokens for a broker (Sign out)",
53966
+ handler: async (req, reply) => this.handleDisconnect(req, reply)
53967
+ }
53968
+ ]);
53969
+ this.ctx.logger.info("Initialized — google broker + calendar-source + gmail smtp", { meta: {
53970
+ brokerCount: this.registry.size(),
53971
+ connectedCount: this.registry.connectedSize(),
53363
53972
  publicOrigin: this.publicOrigin ?? void 0
53364
53973
  } });
53365
53974
  return { providers: [
53975
+ {
53976
+ capability: brokerCapability,
53977
+ provider: this.buildBrokerProvider()
53978
+ },
53366
53979
  {
53367
53980
  capability: calendarSourceCapability,
53368
53981
  provider: calendarProvider
@@ -53377,9 +53990,183 @@ var GoogleServicesAddon = class extends BaseAddon {
53377
53990
  }
53378
53991
  ] };
53379
53992
  }
53380
- async handleStart(reply) {
53381
- if (!this.config.clientId || !this.config.clientSecret) {
53382
- reply.status(400).send({ error: "Configure clientId and clientSecret in Google Services settings first." });
53993
+ async onConfigChanged() {
53994
+ const accounts = this.registry.listOauthAccounts();
53995
+ this.registry.seedFrom(this.config.brokers, accounts);
53996
+ }
53997
+ buildBrokerProvider() {
53998
+ const ownerAddonId = this.ctx.id;
53999
+ const owns = (id) => this.registry.getEntry(id) !== null;
54000
+ const notSupported = async () => {
54001
+ throw new Error("google-services broker: publish/subscribe/getState are not supported — use calendar-source / smtp-provider");
54002
+ };
54003
+ return {
54004
+ list: async ({ kind }) => this.registry.listBrokerInfos(ownerAddonId, kind),
54005
+ get: async ({ id }) => {
54006
+ const entry = this.registry.getEntry(id);
54007
+ return entry ? this.registry.toBrokerInfo(entry, ownerAddonId) : null;
54008
+ },
54009
+ listProviders: async () => [{
54010
+ addonId: ownerAddonId,
54011
+ kinds: [{
54012
+ kind: GOOGLE_BROKER_KIND,
54013
+ label: GOOGLE_BROKER_KIND_LABEL,
54014
+ interactiveConnectPath: `/addon/${ADDON_ID}${START_PATH}?brokerId={{brokerId}}`,
54015
+ interactiveDisconnectPath: `/addon/${ADDON_ID}${DISCONNECT_PATH}?brokerId={{brokerId}}`
54016
+ }]
54017
+ }],
54018
+ add: async ({ kind, name, settings, id }) => {
54019
+ if (kind !== "google") throw new Error(`google-services: only kind '${GOOGLE_BROKER_KIND}' is handled here (got '${kind}')`);
54020
+ const entry = this.registry.createEntry(name, settings, id !== void 0 ? { id } : {});
54021
+ await this.persistBrokers();
54022
+ return { id: entry.id };
54023
+ },
54024
+ remove: async ({ id }) => {
54025
+ if (!owns(id)) return;
54026
+ this.registry.removeEntry(id);
54027
+ await this.persistBrokers();
54028
+ await this.persistOauthAccounts();
54029
+ },
54030
+ testConnection: async ({ id }) => {
54031
+ if (!owns(id)) return {
54032
+ ok: false,
54033
+ error: "unknown broker"
54034
+ };
54035
+ const entry = this.registry.getEntry(id);
54036
+ if (entry === null) return {
54037
+ ok: false,
54038
+ error: "unknown broker"
54039
+ };
54040
+ if (!entry.clientId || !entry.clientSecret) return {
54041
+ ok: false,
54042
+ error: "Configure Client ID and Client secret first"
54043
+ };
54044
+ let redirectUri;
54045
+ try {
54046
+ redirectUri = await this.resolveRedirectUri();
54047
+ } catch (err) {
54048
+ return {
54049
+ ok: false,
54050
+ error: err instanceof Error ? err.message : String(err)
54051
+ };
54052
+ }
54053
+ if (!this.registry.isConnected(id)) return probeGoogleOauthClient({
54054
+ clientId: entry.clientId,
54055
+ clientSecret: entry.clientSecret,
54056
+ redirectUri
54057
+ });
54058
+ try {
54059
+ const started = Date.now();
54060
+ const token = await this.ensureAccessToken(id, { forceRefresh: true });
54061
+ if (token.trim().length === 0) return {
54062
+ ok: false,
54063
+ error: "No access token after refresh — Sign out and Sign in again"
54064
+ };
54065
+ let res = await fetch(GOOGLE_CALENDAR_LIST_URL, { headers: { Authorization: `Bearer ${token}` } });
54066
+ if (res.status === 401) {
54067
+ const retryToken = await this.ensureAccessToken(id, { forceRefresh: true });
54068
+ res = await fetch(GOOGLE_CALENDAR_LIST_URL, { headers: { Authorization: `Bearer ${retryToken}` } });
54069
+ }
54070
+ if (!res.ok) {
54071
+ const text = await res.text();
54072
+ return {
54073
+ ok: false,
54074
+ error: `Google Calendar probe failed (${res.status}): ${text.slice(0, 200)}`
54075
+ };
54076
+ }
54077
+ const email = await this.fetchAccountEmail(token);
54078
+ if (email.length > 0) {
54079
+ const oauth = this.registry.getOauth(id);
54080
+ if (oauth !== null && oauth.accountEmail !== email) {
54081
+ this.registry.setOauth({
54082
+ ...oauth,
54083
+ accountEmail: email
54084
+ });
54085
+ await this.persistOauthAccounts();
54086
+ }
54087
+ }
54088
+ return {
54089
+ ok: true,
54090
+ latencyMs: Date.now() - started
54091
+ };
54092
+ } catch (err) {
54093
+ return {
54094
+ ok: false,
54095
+ error: err instanceof Error ? err.message : String(err)
54096
+ };
54097
+ }
54098
+ },
54099
+ getSettings: async ({ id }) => {
54100
+ const entry = this.registry.getEntry(id);
54101
+ if (entry === null) return null;
54102
+ return brokerEntryToSettings(entry);
54103
+ },
54104
+ setSettings: async ({ id, settings }) => {
54105
+ if (!owns(id)) return;
54106
+ this.registry.updateEntry(id, settings);
54107
+ await this.persistBrokers();
54108
+ },
54109
+ getBrokerConfig: async ({ id }) => {
54110
+ const entry = this.registry.getEntry(id);
54111
+ if (entry === null) return null;
54112
+ const oauth = this.registry.getOauth(id);
54113
+ return {
54114
+ ...brokerEntryToSettings(entry),
54115
+ accountEmail: oauth?.accountEmail ?? "",
54116
+ connected: this.registry.isConnected(id)
54117
+ };
54118
+ },
54119
+ getSettingsSchema: async ({ kind }) => {
54120
+ if (kind !== "google") return null;
54121
+ return buildGoogleBrokerSettingsSchema(this.publicOrigin);
54122
+ },
54123
+ testSettings: async ({ kind, settings }) => {
54124
+ if (kind !== "google") return {
54125
+ ok: false,
54126
+ error: `unsupported kind: ${kind}`
54127
+ };
54128
+ try {
54129
+ const parsed = object({
54130
+ clientId: string().min(1),
54131
+ clientSecret: string().min(1).meta({ secret: true })
54132
+ }).parse(settings);
54133
+ const redirectUri = await this.resolveRedirectUri();
54134
+ return probeGoogleOauthClient({
54135
+ clientId: parsed.clientId,
54136
+ clientSecret: parsed.clientSecret,
54137
+ redirectUri
54138
+ });
54139
+ } catch (err) {
54140
+ return {
54141
+ ok: false,
54142
+ error: err instanceof Error ? err.message : String(err)
54143
+ };
54144
+ }
54145
+ },
54146
+ publish: notSupported,
54147
+ subscribe: notSupported,
54148
+ unsubscribe: notSupported,
54149
+ getState: notSupported,
54150
+ getStatus: async () => ({
54151
+ brokerCount: this.registry.size(),
54152
+ connectedCount: this.registry.connectedSize()
54153
+ })
54154
+ };
54155
+ }
54156
+ async handleStart(req, reply) {
54157
+ const brokerIdRaw = req.query["brokerId"];
54158
+ const brokerId = typeof brokerIdRaw === "string" ? brokerIdRaw.trim() : "";
54159
+ if (!brokerId) {
54160
+ reply.status(400).send({ error: "Missing brokerId query parameter — Connect from Connectivity → Brokers → Edit." });
54161
+ return;
54162
+ }
54163
+ const entry = this.registry.getEntry(brokerId);
54164
+ if (entry === null) {
54165
+ reply.status(404).send({ error: `Unknown Google broker: ${brokerId}` });
54166
+ return;
54167
+ }
54168
+ if (!entry.clientId || !entry.clientSecret) {
54169
+ reply.status(400).send({ error: "Configure Client ID and Client secret on this broker, Save, then Connect." });
53383
54170
  return;
53384
54171
  }
53385
54172
  let redirectUri;
@@ -53389,12 +54176,18 @@ var GoogleServicesAddon = class extends BaseAddon {
53389
54176
  reply.status(500).send({ error: err instanceof Error ? err.message : String(err) });
53390
54177
  return;
53391
54178
  }
54179
+ const popupRaw = req.query["popup"];
54180
+ const popup = popupRaw === "1" || popupRaw === "true";
53392
54181
  const state = (0, node_crypto.randomBytes)(16).toString("hex");
53393
- this.pendingStates.set(state, Date.now());
54182
+ this.pendingStates.set(state, {
54183
+ brokerId,
54184
+ popup,
54185
+ issuedAt: Date.now()
54186
+ });
53394
54187
  this.prunePendingStates();
53395
54188
  const url = buildAuthorizeUrl({
53396
- clientId: this.config.clientId,
53397
- clientSecret: this.config.clientSecret,
54189
+ clientId: entry.clientId,
54190
+ clientSecret: entry.clientSecret,
53398
54191
  redirectUri
53399
54192
  }, state);
53400
54193
  reply.redirect(url);
@@ -53402,42 +54195,138 @@ var GoogleServicesAddon = class extends BaseAddon {
53402
54195
  async handleCallback(req, reply) {
53403
54196
  const err = req.query["error"];
53404
54197
  if (err) {
54198
+ const statePeek = typeof req.query["state"] === "string" ? req.query["state"] : "";
54199
+ const pendingPeek = statePeek ? this.pendingStates.get(statePeek) : void 0;
54200
+ if (pendingPeek?.popup) {
54201
+ this.pendingStates.delete(statePeek);
54202
+ this.popupComplete(reply, {
54203
+ ok: false,
54204
+ brokerId: pendingPeek.brokerId,
54205
+ error: String(err)
54206
+ });
54207
+ return;
54208
+ }
53405
54209
  this.html(reply, 400, `Google OAuth error: ${err}`);
53406
54210
  return;
53407
54211
  }
53408
54212
  const code = req.query["code"];
53409
54213
  const state = req.query["state"];
53410
- if (!code || !state || !this.pendingStates.has(state)) {
54214
+ const pending = typeof state === "string" ? this.pendingStates.get(state) : void 0;
54215
+ if (!code || !state || pending === void 0) {
53411
54216
  this.html(reply, 400, "Invalid or expired OAuth state.");
53412
54217
  return;
53413
54218
  }
53414
54219
  this.pendingStates.delete(state);
54220
+ const { brokerId, popup } = pending;
54221
+ const entry = this.registry.getEntry(brokerId);
54222
+ if (entry === null) {
54223
+ if (popup) {
54224
+ this.popupComplete(reply, {
54225
+ ok: false,
54226
+ brokerId,
54227
+ error: `Unknown Google broker: ${brokerId}`
54228
+ });
54229
+ return;
54230
+ }
54231
+ this.html(reply, 404, `Unknown Google broker: ${brokerId}`);
54232
+ return;
54233
+ }
53415
54234
  try {
53416
54235
  const redirectUri = await this.resolveRedirectUri();
53417
54236
  const tokens = await exchangeCode({
53418
- clientId: this.config.clientId,
53419
- clientSecret: this.config.clientSecret,
54237
+ clientId: entry.clientId,
54238
+ clientSecret: entry.clientSecret,
53420
54239
  redirectUri
53421
54240
  }, code);
53422
- if (!tokens.refresh_token && !this.oauth.refreshToken) throw new Error("Google did not return a refresh_token. Revoke CamStack access in Google Account → Security and retry with prompt=consent.");
53423
- this.oauth = applyTokenResponse(this.oauth, tokens);
53424
- const email = await this.fetchAccountEmail(this.oauth.accessToken);
53425
- if (email) this.oauth = {
53426
- ...this.oauth,
53427
- accountEmail: email
54241
+ const prev = this.registry.getOauth(brokerId) ?? {
54242
+ id: brokerId,
54243
+ ...DEFAULT_OAUTH_STATE
54244
+ };
54245
+ if (!tokens.refresh_token && !prev.refreshToken) throw new Error("Google did not return a refresh_token. Revoke CamStack access in Google Account → Security and retry with prompt=consent.");
54246
+ const applied = applyTokenResponse({
54247
+ refreshToken: prev.refreshToken,
54248
+ accessToken: prev.accessToken,
54249
+ accessExpiresAt: prev.accessExpiresAt,
54250
+ accountEmail: prev.accountEmail
54251
+ }, tokens);
54252
+ const email = await this.fetchAccountEmail(applied.accessToken);
54253
+ const next = {
54254
+ id: brokerId,
54255
+ refreshToken: applied.refreshToken,
54256
+ accessToken: applied.accessToken,
54257
+ accessExpiresAt: applied.accessExpiresAt,
54258
+ accountEmail: email || applied.accountEmail
53428
54259
  };
53429
- await this.persistOauth();
54260
+ this.registry.setOauth(next);
54261
+ await this.persistOauthAccounts();
54262
+ if (next.accountEmail && (entry.name === "Google account" || entry.name.startsWith("google_"))) {
54263
+ const renamed = {
54264
+ ...entry,
54265
+ name: next.accountEmail
54266
+ };
54267
+ this.registry.replaceEntries(this.registry.listEntries().map((e) => e.id === brokerId ? renamed : e));
54268
+ await this.persistBrokers();
54269
+ }
53430
54270
  this.ctx.logger.info("google oauth connected", { meta: {
53431
- hasRefresh: this.oauth.refreshToken.length > 0,
53432
- accountEmail: this.oauth.accountEmail || void 0
54271
+ brokerId,
54272
+ hasRefresh: next.refreshToken.length > 0,
54273
+ accountEmail: next.accountEmail || void 0
53433
54274
  } });
53434
- this.html(reply, 200, "Google account connected (Calendar + Gmail send). You can close this window.");
54275
+ if (popup) {
54276
+ this.popupComplete(reply, {
54277
+ ok: true,
54278
+ brokerId,
54279
+ message: "Google account connected. This window should close."
54280
+ });
54281
+ return;
54282
+ }
54283
+ this.html(reply, 200, `Google account connected for ${brokerId} (Calendar + Gmail send). You can close this window.`);
53435
54284
  } catch (e) {
53436
54285
  const message = e instanceof Error ? e.message : String(e);
53437
- this.ctx.logger.warn("google oauth callback failed", { meta: { error: message } });
54286
+ this.ctx.logger.warn("google oauth callback failed", { meta: {
54287
+ brokerId,
54288
+ error: message
54289
+ } });
54290
+ if (popup) {
54291
+ this.popupComplete(reply, {
54292
+ ok: false,
54293
+ brokerId,
54294
+ error: message
54295
+ });
54296
+ return;
54297
+ }
53438
54298
  this.html(reply, 500, `OAuth failed: ${message}`);
53439
54299
  }
53440
54300
  }
54301
+ /**
54302
+ * Popup completion page (OIDC D794 pattern): postMessage to opener at the
54303
+ * hub origin, then close. Google never redirects back to admin-ui — this
54304
+ * page is the only return surface.
54305
+ */
54306
+ popupComplete(reply, outcome) {
54307
+ const payload = JSON.stringify({
54308
+ type: "camstack:broker-oauth",
54309
+ ok: outcome.ok,
54310
+ brokerId: outcome.brokerId,
54311
+ ...outcome.error !== void 0 ? { error: outcome.error } : {}
54312
+ });
54313
+ const body = outcome.ok ? outcome.message ?? "Done. This window should close." : `OAuth failed: ${outcome.error ?? "unknown"}`;
54314
+ reply.header("content-type", "text/html; charset=utf-8");
54315
+ reply.status(outcome.ok ? 200 : 400).send(`<!doctype html><html><head><title>Google</title></head><body>
54316
+ <p>${escapeHtml(body)}</p>
54317
+ <script>
54318
+ (function () {
54319
+ var msg = ${payload};
54320
+ try {
54321
+ if (window.opener) {
54322
+ window.opener.postMessage(msg, window.location.origin);
54323
+ }
54324
+ } catch (e) {}
54325
+ setTimeout(function () { window.close(); }, 200);
54326
+ })();
54327
+ <\/script>
54328
+ </body></html>`);
54329
+ }
53441
54330
  async fetchAccountEmail(accessToken) {
53442
54331
  try {
53443
54332
  const res = await fetch(GOOGLE_USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
@@ -53454,27 +54343,92 @@ var GoogleServicesAddon = class extends BaseAddon {
53454
54343
  }
53455
54344
  prunePendingStates() {
53456
54345
  const cutoff = Date.now() - 10 * 6e4;
53457
- for (const [k, at] of this.pendingStates) if (at < cutoff) this.pendingStates.delete(k);
54346
+ for (const [k, v] of this.pendingStates) if (v.issuedAt < cutoff) this.pendingStates.delete(k);
54347
+ }
54348
+ async persistBrokers() {
54349
+ await this.updateGlobalSettings({ brokers: [...this.registry.listEntries()] });
53458
54350
  }
53459
- async persistOauth() {
53460
- if (!this.oauthHandle) return;
53461
- await this.oauthHandle.set({ ...this.oauth });
54351
+ async persistOauthAccounts() {
54352
+ if (!this.oauthAccountsHandle) return;
54353
+ await this.oauthAccountsHandle.set({ accounts: [...this.registry.listOauthAccounts()] });
53462
54354
  }
53463
- async ensureAccessToken() {
53464
- if (!this.oauth.refreshToken) throw new Error("Google Calendar is not connected — open /addon/google-services/oauth/start");
53465
- if (!needsRefresh(this.oauth)) return this.oauth.accessToken;
54355
+ /** Stamped catalog for Connectivity → Calendars (`optionsFrom`). */
54356
+ async loadStampedCalendarCatalog() {
54357
+ const out = [];
54358
+ for (const entry of this.registry.listEntries()) {
54359
+ if (!this.registry.isConnected(entry.id)) continue;
54360
+ try {
54361
+ const list = await createGoogleCalendarApi(() => this.ensureAccessToken(entry.id)).listCalendars();
54362
+ const accountLabel = this.registry.getOauth(entry.id)?.accountEmail || entry.name;
54363
+ for (const c of list) {
54364
+ const summary = c.summary.trim().length > 0 ? c.summary : c.id;
54365
+ out.push({
54366
+ value: stampCalendarId(entry.id, c.id),
54367
+ label: `${summary} (${accountLabel})`
54368
+ });
54369
+ }
54370
+ } catch (err) {
54371
+ this.ctx.logger.warn("google calendar catalog failed", { meta: {
54372
+ brokerId: entry.id,
54373
+ error: err instanceof Error ? err.message : String(err)
54374
+ } });
54375
+ }
54376
+ }
54377
+ return out.toSorted((a, b) => a.label.localeCompare(b.label));
54378
+ }
54379
+ async ensureAccessToken(brokerId, opts = {}) {
54380
+ const entry = this.registry.getEntry(brokerId);
54381
+ const oauth = this.registry.getOauth(brokerId);
54382
+ if (entry === null || oauth === null || !oauth.refreshToken.trim()) throw new Error(`Google broker ${brokerId} is not connected — use Sign in under Connectivity → Brokers`);
54383
+ if (!entry.clientId.trim() || !entry.clientSecret.trim()) throw new Error("Configure Client ID and Client secret before refreshing the Google token");
54384
+ const state = {
54385
+ refreshToken: oauth.refreshToken.trim(),
54386
+ accessToken: oauth.accessToken.trim(),
54387
+ accessExpiresAt: oauth.accessExpiresAt,
54388
+ accountEmail: oauth.accountEmail
54389
+ };
54390
+ if (!opts.forceRefresh && !needsRefresh(state)) return state.accessToken;
53466
54391
  const tokens = await refreshAccessToken({
53467
- clientId: this.config.clientId,
53468
- clientSecret: this.config.clientSecret
53469
- }, this.oauth.refreshToken);
53470
- this.oauth = applyTokenResponse(this.oauth, tokens);
53471
- await this.persistOauth();
53472
- return this.oauth.accessToken;
54392
+ clientId: entry.clientId.trim(),
54393
+ clientSecret: entry.clientSecret.trim()
54394
+ }, state.refreshToken);
54395
+ if (typeof tokens.access_token !== "string" || tokens.access_token.trim().length === 0) throw new Error("Google token refresh returned an empty access_token — Sign out and Sign in again");
54396
+ const nextState = applyTokenResponse(state, tokens);
54397
+ this.registry.setOauth({
54398
+ id: brokerId,
54399
+ refreshToken: nextState.refreshToken,
54400
+ accessToken: nextState.accessToken,
54401
+ accessExpiresAt: nextState.accessExpiresAt,
54402
+ accountEmail: nextState.accountEmail
54403
+ });
54404
+ await this.persistOauthAccounts();
54405
+ return nextState.accessToken;
53473
54406
  }
53474
- parseFilter() {
53475
- const raw = this.config.calendarFilter.trim();
53476
- if (!raw) return /* @__PURE__ */ new Set();
53477
- return new Set(raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0));
54407
+ async handleDisconnect(req, reply) {
54408
+ const brokerIdRaw = req.query["brokerId"];
54409
+ const brokerId = typeof brokerIdRaw === "string" ? brokerIdRaw.trim() : "";
54410
+ if (!brokerId) {
54411
+ reply.status(400).send({ error: "Missing brokerId query parameter" });
54412
+ return;
54413
+ }
54414
+ if (this.registry.getEntry(brokerId) === null) {
54415
+ reply.status(404).send({ error: `Unknown Google broker: ${brokerId}` });
54416
+ return;
54417
+ }
54418
+ const popupRaw = req.query["popup"];
54419
+ const popup = popupRaw === "1" || popupRaw === "true";
54420
+ this.registry.removeOauth(brokerId);
54421
+ await this.persistOauthAccounts();
54422
+ this.ctx.logger.info("google oauth disconnected", { meta: { brokerId } });
54423
+ if (popup) {
54424
+ this.popupComplete(reply, {
54425
+ ok: true,
54426
+ brokerId,
54427
+ message: "Google account disconnected. This window should close."
54428
+ });
54429
+ return;
54430
+ }
54431
+ this.html(reply, 200, `Google account disconnected for ${brokerId}. You can close this window.`);
53478
54432
  }
53479
54433
  async resolveRedirectUri() {
53480
54434
  await this.refreshPublicOrigin();
@@ -53490,85 +54444,22 @@ var GoogleServicesAddon = class extends BaseAddon {
53490
54444
  }
53491
54445
  }
53492
54446
  /**
53493
- * Connectivity asks for settings scoped to the page capability, so Calendar
53494
- * and Email do not each redraw the other's fields (same pattern as
53495
- * mqtt-broker under `broker`). The addon page (no cap) keeps every section.
54447
+ * Connectivity Calendars / Email: point at Brokers (no duplicate OAuth fields).
54448
+ * Addon page without cap: same pointer + note.
53496
54449
  */
53497
54450
  globalSettingsSchema(cap) {
53498
54451
  const scoped = asSettingsCap(cap);
53499
- return this.schema({ sections: [
53500
- {
53501
- id: "setup-intro",
53502
- title: "Setup",
53503
- description: scoped === "smtp-provider" ? "Connect a Google account for Gmail send (same OAuth as Calendars)." : scoped === "calendar-source" ? "Connect a Google account for Calendar (same OAuth as Email)." : "One Google account for Calendar and optional Gmail send.",
53504
- columns: 1,
53505
- fields: [{
53506
- type: "info",
53507
- key: "_setupSteps",
53508
- label: "Steps",
53509
- format: "html",
53510
- content: introHtml(scoped, this.publicOrigin)
53511
- }]
53512
- },
53513
- {
53514
- id: "google-oauth",
53515
- title: "Google account",
53516
- description: "OAuth client credentials (Web application) stored in the hub keychain.",
53517
- columns: 1,
53518
- fields: [
53519
- {
53520
- type: "text",
53521
- key: "clientId",
53522
- label: "Client ID"
53523
- },
53524
- {
53525
- type: "password",
53526
- key: "clientSecret",
53527
- label: "Client secret",
53528
- showToggle: true
53529
- },
53530
- {
53531
- type: "info",
53532
- key: "_connectInfo",
53533
- label: "Connect",
53534
- format: "html",
53535
- content: connectHtml(scoped, this.publicOrigin)
53536
- }
53537
- ]
53538
- },
53539
- ...scoped === "smtp-provider" ? [] : [{
53540
- id: "google-calendar",
53541
- title: "Google Calendar",
53542
- description: "Which calendars this hub exposes as calendar-source entries.",
53543
- columns: 1,
53544
- fields: [{
53545
- type: "text",
53546
- key: "calendarFilter",
53547
- label: "Calendar id filter (optional)",
53548
- description: "Comma-separated Google calendar ids to expose. Empty = all calendars the account can read."
53549
- }]
53550
- }],
53551
- ...scoped === "calendar-source" ? [] : [{
53552
- id: "gmail-send",
53553
- title: "Gmail send",
53554
- description: "Delivery via the Gmail API. Disable this capability with the page Enable toggle — not a second switch here. From must be the connected account or a verified send-as.",
53555
- columns: 1,
53556
- fields: [{
53557
- type: "text",
53558
- key: "fromAddress",
53559
- label: "From address",
53560
- description: "Defaults to the connected Google account email when empty."
53561
- }, {
53562
- type: "text",
53563
- key: "fromName",
53564
- label: "From display name (optional)"
53565
- }]
53566
- }]
53567
- ] });
54452
+ return this.schema(buildGoogleFeatureSettingsSchema(scoped, this.publicOrigin));
53568
54453
  }
53569
54454
  async getGlobalSettings(overlay, cap, nodeId) {
53570
54455
  await this.refreshPublicOrigin();
53571
- return super.getGlobalSettings(overlay, cap, nodeId);
54456
+ if (cap !== "calendar-source") return super.getGlobalSettings(overlay, cap, nodeId);
54457
+ const catalog = await this.loadStampedCalendarCatalog();
54458
+ return super.getGlobalSettings({
54459
+ ...overlay,
54460
+ calendarIds: this.config.calendarIds ?? [],
54461
+ ...catalog.length > 0 ? { _calendarCatalog: catalog } : {}
54462
+ }, cap, nodeId);
53572
54463
  }
53573
54464
  };
53574
54465
  function asSettingsCap(cap) {
@@ -53579,10 +54470,12 @@ function escapeHtml(s) {
53579
54470
  }
53580
54471
  //#endregion
53581
54472
  exports.DEFAULT_GOOGLE_CONFIG = DEFAULT_GOOGLE_CONFIG;
54473
+ exports.GOOGLE_BROKER_KIND = GOOGLE_BROKER_KIND;
53582
54474
  exports.GOOGLE_CONSOLE_APIS_URL = GOOGLE_CONSOLE_APIS_URL;
53583
54475
  exports.GOOGLE_CONSOLE_CREDENTIALS_URL = GOOGLE_CONSOLE_CREDENTIALS_URL;
53584
54476
  exports.GOOGLE_GMAIL_SEND_SCOPE = GOOGLE_GMAIL_SEND_SCOPE;
53585
54477
  exports.GOOGLE_OAUTH_SCOPES = GOOGLE_OAUTH_SCOPES;
54478
+ exports.GoogleBrokerRegistry = GoogleBrokerRegistry;
53586
54479
  exports.GoogleServicesAddon = GoogleServicesAddon;
53587
54480
  exports.default = GoogleServicesAddon;
53588
54481
  exports.GoogleServicesConfigSchema = GoogleServicesConfigSchema;
@@ -53597,6 +54490,10 @@ exports.createGoogleCalendarSource = createGoogleCalendarSource;
53597
54490
  exports.exchangeCode = exchangeCode;
53598
54491
  exports.introHtml = introHtml;
53599
54492
  exports.mapGoogleEvent = mapGoogleEvent;
54493
+ exports.migrateLegacyToBrokers = migrateLegacyToBrokers;
53600
54494
  exports.needsRefresh = needsRefresh;
54495
+ exports.parseStampedCalendarId = parseStampedCalendarId;
53601
54496
  exports.redirectUriDisplay = redirectUriDisplay;
53602
54497
  exports.refreshAccessToken = refreshAccessToken;
54498
+ exports.selectGmailAccount = selectGmailAccount;
54499
+ exports.stampCalendarId = stampCalendarId;