@foir/sdk 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -108,6 +108,49 @@ nobody asking for `@foir/sdk` gets it early. The push that carries the commit to
108
108
 
109
109
  Changes on `dev` that have not been published to npm.
110
110
 
111
+ ## [0.16.0] — 2026-10-02
112
+
113
+ Published under npm's `next` tag from `dev`, as 0.15.0 was; `latest` moves to
114
+ it at the next promotion.
115
+
116
+ ### Added
117
+
118
+ - **An embedded page is told how the console looks** (`@foir/sdk/placement`).
119
+ The console's theme is the operator's own setting, which a framed page cannot
120
+ see, so a page that followed the system's preference could sit dark inside a
121
+ light console. `connectPlacement` takes `onAppearance`, called with
122
+ `{ theme: 'light' | 'dark' }` as soon as the console says (before the token
123
+ arrives) and again whenever the operator changes it; `session.appearance`
124
+ holds the current one. `PlacementAppearance` is the type, and
125
+ `PLACEMENT_MESSAGE.appearance` the message.
126
+ - A console older than this says nothing: `onAppearance` is not called and
127
+ `session.appearance` is `null`, so keep the system's preference as the
128
+ fallback.
129
+
130
+ ## [0.15.0] — 2026-10-02
131
+
132
+ Published under npm's `next` tag from `dev`, because it calls parts of the
133
+ platform that production serves from the next promotion; `latest` moves to it
134
+ then.
135
+
136
+ ### Added
137
+
138
+ - **`notifications.setConsent` answers `confirmationPending`**, and takes
139
+ `givenElsewhere` and `locale` (`@foir/sdk/app`). A project can ask that an
140
+ opt-in an app collects in a category is confirmed by email before it counts.
141
+ In such a category an opt-in writes nothing: the platform sends the customer
142
+ one confirmation email, in the page's `locale` when they have stated no
143
+ language of their own, and the answer says `confirmationPending`. The
144
+ preference turns on when they follow the link. Everywhere else an opt-in is
145
+ applied at once, as before.
146
+ - A consent the app carries over from the system it was given in says
147
+ `givenElsewhere: true`, and is applied as it stands.
148
+
149
+ ### Changed
150
+
151
+ - `notifications.setConsent` asks the platform for `confirmationPending`, a
152
+ field a platform older than this release does not have.
153
+
111
154
  ## [0.14.0] — 2026-10-01
112
155
 
113
156
  Published under npm's `next` tag from `dev`, because it calls parts of the
@@ -183,6 +183,22 @@ export interface AppConsentChange {
183
183
  * answering `stale`.
184
184
  */
185
185
  sourceModifiedAt?: string;
186
+ /**
187
+ * True when the customer gave this consent in another system and the app is
188
+ * carrying it over (a mirror). It is applied as it stands.
189
+ *
190
+ * Left out, the app collected the consent itself (a sign-up form), and an
191
+ * opt-in is applied the way its category says: at once, or, in a category
192
+ * that confirms by email, only when the customer follows the link the
193
+ * platform sends them. Until then nothing is written and the answer says
194
+ * `confirmationPending`.
195
+ */
196
+ givenElsewhere?: boolean;
197
+ /**
198
+ * The language of the page the consent was collected on. The confirmation
199
+ * email is written in it when the customer has stated none of their own.
200
+ */
201
+ locale?: string;
186
202
  }
187
203
  /** What the calling token is, and what it is served. Read from the platform, never off the token. */
188
204
  export interface AppWhoami {
@@ -409,9 +425,16 @@ export interface AppClient {
409
425
  /**
410
426
  * Set a named customer's consent in an opt-in category. Needs
411
427
  * `notifications:consent`, which only an operation's token carries.
428
+ *
429
+ * `confirmationPending` is true when an opt-in the app collected was not
430
+ * applied because its category confirms by email first: the customer has
431
+ * been sent a link, or was sent one recently, and the preference turns on
432
+ * when they follow it. The answer is the same whether or not the address
433
+ * already had a customer.
412
434
  */
413
435
  setConsent(change: AppConsentChange): Promise<{
414
436
  stale: boolean;
437
+ confirmationPending: boolean;
415
438
  }>;
416
439
  };
417
440
  operations: {
package/dist/app.js CHANGED
@@ -825,13 +825,15 @@ function createAppClient(config) {
825
825
  enabled: change.enabled
826
826
  };
827
827
  if (change.sourceModifiedAt !== void 0) input.sourceModifiedAt = change.sourceModifiedAt;
828
+ if (change.givenElsewhere !== void 0) input.givenElsewhere = change.givenElsewhere;
829
+ if (change.locale !== void 0) input.locale = change.locale;
828
830
  const answer = await field(
829
831
  "setCustomerConsent",
830
- `mutation AppSetCustomerConsent($input: SetCustomerConsentInput!) { setCustomerConsent(input: $input) { stale } }`,
832
+ `mutation AppSetCustomerConsent($input: SetCustomerConsentInput!) { setCustomerConsent(input: $input) { stale confirmationPending } }`,
831
833
  { input }
832
834
  );
833
835
  if (!answer) return refuse("setCustomerConsent reported no error and answered nothing");
834
- return { stale: answer.stale === true };
836
+ return { stale: answer.stale === true, confirmationPending: answer.confirmationPending === true };
835
837
  }
836
838
  },
837
839
  operations: {
@@ -19,6 +19,10 @@
19
19
  * const data = await session.request('{ preorders { items { id } } }');
20
20
  * ```
21
21
  *
22
+ * The console also tells the page how it looks, so the page can match it: pass
23
+ * `onAppearance` and apply the theme it names. The operator's console theme is
24
+ * their own setting and need not be their system's.
25
+ *
22
26
  * The browser shows the page blank, however well it is authenticated, unless
23
27
  * the page allows the console as a frame ancestor. Hydrogen and many other
24
28
  * frameworks send `frame-ancestors 'none'` by default. `placementFrameAncestors`
@@ -37,6 +41,8 @@ export declare const PLACEMENT_MESSAGE: {
37
41
  readonly refused: "foir:placement:refused";
38
42
  /** Console to page: the operator signed out. */
39
43
  readonly signedOut: "foir:placement:signed-out";
44
+ /** Console to page: how the console looks, now and whenever it changes. */
45
+ readonly appearance: "foir:placement:appearance";
40
46
  };
41
47
  /** What the console hands an embedded page. */
42
48
  export interface PlacementGrant {
@@ -54,6 +60,14 @@ export interface PlacementGrant {
54
60
  reason: string;
55
61
  }[];
56
62
  }
63
+ /** How the console looks, for an embedded page to match. */
64
+ export interface PlacementAppearance {
65
+ /**
66
+ * The console's theme as the operator sees it: their own choice, or their
67
+ * system's when they left it to the system.
68
+ */
69
+ theme: 'light' | 'dark';
70
+ }
57
71
  /** A message the console sends an embedded page. */
58
72
  export type PlacementHostMessage = {
59
73
  type: typeof PLACEMENT_MESSAGE.token;
@@ -63,6 +77,9 @@ export type PlacementHostMessage = {
63
77
  reason: string;
64
78
  } | {
65
79
  type: typeof PLACEMENT_MESSAGE.signedOut;
80
+ } | {
81
+ type: typeof PLACEMENT_MESSAGE.appearance;
82
+ appearance: PlacementAppearance;
66
83
  };
67
84
  /** A message an embedded page sends the console. */
68
85
  export type PlacementPageMessage = {
@@ -100,6 +117,13 @@ export interface ConnectPlacementOptions {
100
117
  timeoutMs?: number;
101
118
  /** How long before expiry to ask for a fresh token. Default 60 seconds. */
102
119
  refreshBeforeMs?: number;
120
+ /**
121
+ * Called with how the console looks: once as soon as the console says, which
122
+ * is before the token arrives, and again whenever the operator changes it.
123
+ * Apply it instead of the system's preference, so the page matches the
124
+ * console it is shown in.
125
+ */
126
+ onAppearance?: (appearance: PlacementAppearance) => void;
103
127
  /** The page's window. Default the global one; tests pass their own. */
104
128
  window?: Window;
105
129
  /** The fetch the session's requests use. Default the global one. */
@@ -109,6 +133,11 @@ export interface ConnectPlacementOptions {
109
133
  export interface PlacementSession {
110
134
  /** The grant in force now. Replaced as the session refreshes. */
111
135
  readonly grant: PlacementGrant;
136
+ /**
137
+ * How the console looks now, or null when the console has not said (a console
138
+ * older than this). Kept current as the operator changes it.
139
+ */
140
+ readonly appearance: PlacementAppearance | null;
112
141
  /** Whether the token carries this scope, to hide or grey out what the operator cannot do. */
113
142
  can(scope: string): boolean;
114
143
  /** A GraphQL request to Foir's API as the operator, through your app. */
package/dist/placement.js CHANGED
@@ -9,7 +9,9 @@ var PLACEMENT_MESSAGE = {
9
9
  /** Console to page: no token, and why. */
10
10
  refused: "foir:placement:refused",
11
11
  /** Console to page: the operator signed out. */
12
- signedOut: "foir:placement:signed-out"
12
+ signedOut: "foir:placement:signed-out",
13
+ /** Console to page: how the console looks, now and whenever it changes. */
14
+ appearance: "foir:placement:appearance"
13
15
  };
14
16
  var FOIR_CONSOLE_ORIGINS = {
15
17
  production: ["https://app.foir.io"],
@@ -41,6 +43,10 @@ var PlacementRefused = class extends Error {
41
43
  function isHostMessage(data) {
42
44
  if (typeof data !== "object" || data === null) return false;
43
45
  const t = data.type;
46
+ if (t === PLACEMENT_MESSAGE.appearance) {
47
+ const theme = data.appearance?.theme;
48
+ return theme === "light" || theme === "dark";
49
+ }
44
50
  return t === PLACEMENT_MESSAGE.token || t === PLACEMENT_MESSAGE.refused || t === PLACEMENT_MESSAGE.signedOut;
45
51
  }
46
52
  function connectPlacement(options) {
@@ -55,6 +61,7 @@ function connectPlacement(options) {
55
61
  }
56
62
  return new Promise((resolve, reject) => {
57
63
  let grant = null;
64
+ let appearance = null;
58
65
  let settled = false;
59
66
  let refreshTimer;
60
67
  const signedOut = /* @__PURE__ */ new Set();
@@ -77,6 +84,9 @@ function connectPlacement(options) {
77
84
  if (!grant) throw new PlacementRefused("the operator has signed out");
78
85
  return grant;
79
86
  },
87
+ get appearance() {
88
+ return appearance;
89
+ },
80
90
  can: (scope) => grant?.grantedScopes.includes(scope) ?? false,
81
91
  async request(document, variables) {
82
92
  if (!grant) throw new PlacementRefused("the operator has signed out");
@@ -123,6 +133,10 @@ function connectPlacement(options) {
123
133
  close();
124
134
  for (const l of signedOut) l();
125
135
  return;
136
+ case PLACEMENT_MESSAGE.appearance:
137
+ appearance = { theme: msg.appearance.theme };
138
+ options.onAppearance?.(appearance);
139
+ return;
126
140
  }
127
141
  }
128
142
  const timeout = setTimeout(() => {
@@ -4,8 +4,8 @@
4
4
  * Used by config API backends (e.g., Hono servers) to query the platform
5
5
  * GraphQL API at runtime. Provisioning (models, operations, schedules) is
6
6
  * handled by `foir push`. Record writes from upstream sources go through
7
- * the scoped-token `upsertRecord` GraphQL mutation served by the
8
- * integrations gateway, not through this client.
7
+ * the scoped-token `upsertRecord` GraphQL mutation an app's token reaches
8
+ * (`@foir/sdk/app`), not through this client.
9
9
  *
10
10
  * Auth: All requests use an API key (x-api-key header).
11
11
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foir/sdk",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Official Foir SDK — editor iframe client + server-side platform helpers",
5
5
  "type": "module",
6
6
  "publishConfig": {