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