@adobe/alloy-node 0.1.0 → 0.2.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/README.md CHANGED
@@ -100,6 +100,19 @@ separate wiring needed. If you build a custom instance with
100
100
  `setConsent` will still exist as a method but will reject when called, the
101
101
  same as calling an unregistered command in the browser bundle.
102
102
 
103
+ > [!WARNING]
104
+ > `defaultConsent: "pending"` is rejected by `configure()` — not supported
105
+ > in Node. A pending `sendEvent()`/`getIdentity()` on one `forRequest()`
106
+ > call can only ever be released by a `setConsent()` on that *exact same*
107
+ > call, never by a later request for the same visitor (each `forRequest()`
108
+ > is a fresh instance with its own private pending queue) — unlike a
109
+ > browser page, Node has no equivalent long-lived session to hold that
110
+ > state across separate HTTP requests. Use `"in"` or `"out"`, and gate
111
+ > calling `sendEvent()`/`setConsent()` on your own persisted consent
112
+ > decision instead. If you need the multi-request pending flow to work,
113
+ > let us know — we haven't built it because we don't yet have signal that
114
+ > anyone needs it, not because it's impossible.
115
+
103
116
  ## Context
104
117
 
105
118
  A minimal, always-on Context component attaches `implementationDetails` to
@@ -124,6 +137,95 @@ local timezone) has no honest server-side source and isn't guessed at here —
124
137
  merge real values directly via `sendEvent({ xdm: { ... } })` if you have
125
138
  them.
126
139
 
140
+ ## Authenticated requests (Edge Network Server API, v2)
141
+
142
+ Because Node runs in a trusted, client-controlled environment (unlike a
143
+ browser), it authenticates its own requests instead of sending them
144
+ unauthenticated — `configure()` **requires** OAuth Server-to-Server
145
+ credentials from the [Adobe Developer Console](https://developer.adobe.com/console):
146
+
147
+ ```js
148
+ await alloy.configure({
149
+ orgId: "...",
150
+ datastreamId: "...",
151
+ edgeCredentials: {
152
+ clientId: "...",
153
+ clientSecret: "...",
154
+ scopes: ["openid", "AdobeID", ...], // whatever your project grants
155
+ },
156
+ });
157
+ ```
158
+
159
+ `configure()` rejects if `edgeCredentials` is missing — unlike the browser
160
+ bundle, where the same config field is optional (and generally shouldn't be
161
+ used at all: shipping a client secret to a browser defeats the point of it
162
+ being a secret). `sendEvent()` then goes to the authenticated Edge Network
163
+ Server API (`server.adobedc.net`, `v2`) instead of the standard one, with a
164
+ real IMS access token attached.
165
+
166
+ The IMS token is fetched once and cached (~24h) for the life of the
167
+ process, not once per request — it's server-credential state, not visitor
168
+ state, shared automatically across every `forRequest()` call the same way
169
+ `network`/`runtime` already are.
170
+
171
+ ### Running the real-network integration suite
172
+
173
+ `packages/node/test/integration/nodeConsumer.spec.js` makes requests to
174
+ authentication-required APIs, so it needs a real credential — it reads
175
+ `CLIENT_ID`/`CLIENT_SECRET`/`SCOPES`/`IMS_ORG_ID`/`DATASTREAM_ID` from
176
+ `process.env` and skips itself (doesn't fail) whenever any of them are
177
+ unset. `DATASTREAM_ID` must be a real datastream under that same
178
+ `IMS_ORG_ID`.
179
+
180
+ Because it needs credentials, this suite does not run with the basic
181
+ `pnpm run test`/`test:coverage` testing commands (which exclude it via
182
+ `--project=!node-integration`) into its own command:
183
+
184
+ ```sh
185
+ # Locally, from a .env file at the repo root (gitignored):
186
+ node --env-file=.env ./node_modules/.bin/vitest run --project node-integration
187
+ # or, equivalently:
188
+ node --env-file=.env ./node_modules/.bin/pnpm run test:node-integration
189
+ ```
190
+
191
+ In CI (`.github/workflows/quality-checks.yml`'s `tests` job), `pnpm run
192
+ test:node-integration` is its own step, with those same env vars populated
193
+ from repo secrets — `ALLOY_NODE_INTEGRATION_CLIENT_ID`, `_CLIENT_SECRET`,
194
+ `_SCOPES`, `_IMS_ORG_ID`, `_DATASTREAM_ID` — configured under this repo's
195
+ **Settings → Secrets and variables → Actions**. Until those secrets exist,
196
+ the suite just keeps skipping in CI, same as it does locally without a
197
+ `.env`. GitHub doesn't expose secrets to a fork's `pull_request` run either
198
+ way, so it'll always skip there regardless.
199
+
200
+ Verified against a real, credentialed project — two things worth knowing,
201
+ neither of which needs any code changes on your part, but do affect what
202
+ you pass to `sendEvent()`:
203
+
204
+ - **Only `sendEvent` uses v2.** `setConsent` and `getIdentity` still go
205
+ through the standard v1 endpoint even when `edgeCredentials` is
206
+ configured — the authenticated Server API returns a flat 404 for
207
+ `privacy/set-consent` and `identity/acquire`. This is handled
208
+ automatically; you don't need to do anything differently for those
209
+ calls.
210
+ - **You must supply a primary identity.** Since there's no browser cookie
211
+ jar for Edge Network to resolve identity from on a pure server-to-server
212
+ call, an authenticated `sendEvent()` needs an explicit identity in the
213
+ event itself, or it's rejected:
214
+
215
+ ```js
216
+ await request.sendEvent({
217
+ xdm: {
218
+ eventType: "...",
219
+ identityMap: {
220
+ Email: [{ id: "visitor@example.com", primary: true }],
221
+ },
222
+ },
223
+ });
224
+ ```
225
+
226
+ (The `events` → `event` payload reshaping v2 requires under the hood is
227
+ handled internally — nothing to do there either.)
228
+
127
229
  ## Platform services
128
230
 
129
231
  Both `createInstance(options)` and `forRequest(overrides)` accept a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/alloy-node",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Adobe Experience Platform Web SDK - Node.js entrypoint",
5
5
  "type": "module",
6
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@adobe/alloy-core": "^1.2.3"
27
+ "@adobe/alloy-core": "^1.3.0"
28
28
  },
29
29
  "scripts": {
30
30
  "typecheck": "tsc -p jsconfig.json"
@@ -4,9 +4,19 @@ declare module "@adobe/alloy-core" {
4
4
  options: Record<string, unknown>,
5
5
  createPlatformServices?: () => unknown,
6
6
  coreConfigValidators?: unknown,
7
+ getImsAccessToken?: { getAccessToken: () => Promise<string> },
7
8
  ): (commandName: string, options?: Record<string, unknown>) => Promise<any>;
8
9
 
9
10
  export function createCoreConfigs(): unknown;
10
11
 
12
+ export function createGetImsAccessToken(options: {
13
+ edgeCredentials: {
14
+ clientId: string;
15
+ clientSecret: string;
16
+ scopes: string[];
17
+ imsHost: string;
18
+ };
19
+ }): { getAccessToken: () => Promise<string> };
20
+
11
21
  export function consent(options: Record<string, unknown>): unknown;
12
22
  }
@@ -15,7 +15,7 @@ governing permissions and limitations under the License.
15
15
  import packageJson from "../../package.json" with { type: "json" };
16
16
  import getHeader from "../services/getHeader.js";
17
17
 
18
- const LIBRARY_NAME = "https://ns.adobe.com/experience/alloy";
18
+ const LIBRARY_NAME = "https://ns.adobe.com/experience/alloy-node";
19
19
  const { version: LIBRARY_VERSION } = packageJson;
20
20
 
21
21
  /**
@@ -16,7 +16,9 @@ governing permissions and limitations under the License.
16
16
  import {
17
17
  createCustomInstance as createCoreCustomInstance,
18
18
  createCoreConfigs,
19
+ createGetImsAccessToken,
19
20
  } from "@adobe/alloy-core";
21
+ import { DEFAULT_IMS_HOST } from "@adobe/alloy-core/constants/domain.js";
20
22
  import createNodePlatformServices from "./services/createNodePlatformServices.js";
21
23
  import * as allRequiredComponents from "./components/requiredComponentCreators.js";
22
24
 
@@ -97,6 +99,10 @@ const createNodeAlloy = ({
97
99
 
98
100
  /** @type {Record<string, unknown> | undefined} */
99
101
  let capturedConfig;
102
+ // Built once, after configure() resolves, and shared across every
103
+ // forRequest() call — see createGetImsAccessToken's docs for why.
104
+ /** @type {ReturnType<typeof createGetImsAccessToken> | undefined} */
105
+ let sharedGetImsAccessToken;
100
106
 
101
107
  /**
102
108
  * A handle scoped to a single request, backed by its own fresh instance
@@ -142,6 +148,7 @@ const createNodeAlloy = ({
142
148
  // request, so they must not be checked for uniqueness against every
143
149
  // other request this process has ever handled.
144
150
  createCoreConfigs(),
151
+ sharedGetImsAccessToken,
145
152
  );
146
153
  requestExecuteCommand("configure", capturedConfig);
147
154
  return bindCommandMethods(requestExecuteCommand, COMMAND_NAMES);
@@ -151,12 +158,48 @@ const createNodeAlloy = ({
151
158
  ...bindCommandMethods(executeCommand, COMMAND_NAMES),
152
159
  /** @param {Record<string, unknown>} options */
153
160
  configure(options) {
161
+ if (options.defaultConsent === "pending") {
162
+ return Promise.reject(
163
+ new Error(
164
+ '@adobe/alloy-node does not support defaultConsent: "pending" — ' +
165
+ "Node has no cross-request session to hold a pending decision " +
166
+ 'in (unlike a long-lived browser page). Use "in" or "out", and ' +
167
+ "gate calling sendEvent()/setConsent() on your own persisted " +
168
+ "consent decision instead.",
169
+ ),
170
+ );
171
+ }
172
+
173
+ // Node runs in a trusted, client-controlled environment, so
174
+ // authenticating is required here, unlike the browser bundle's
175
+ // optional opt-in.
176
+ if (!options.edgeCredentials) {
177
+ return Promise.reject(
178
+ new Error(
179
+ "@adobe/alloy-node requires edgeCredentials. See the README for " +
180
+ "how to obtain OAuth Server-to-Server credentials from the " +
181
+ "Adobe Developer Console.",
182
+ ),
183
+ );
184
+ }
185
+
154
186
  // Only capture the config once configure() has actually succeeded —
155
187
  // if it rejects (invalid config), capturedConfig must stay unset, so
156
188
  // a subsequent forRequest() call still throws instead of silently
157
189
  // reconfiguring every request with a config core already rejected.
158
190
  return executeCommand("configure", options).then((result) => {
159
191
  capturedConfig = options;
192
+ // core's config validation applies this same default, but only to
193
+ // its own internal copy — this instance's token accessor is built
194
+ // from the raw options, so an omitted imsHost would otherwise
195
+ // become the literal string "undefined" in the token URL.
196
+ const edgeCredentials = /** @type {any} */ (options).edgeCredentials;
197
+ sharedGetImsAccessToken = createGetImsAccessToken({
198
+ edgeCredentials: {
199
+ ...edgeCredentials,
200
+ imsHost: edgeCredentials.imsHost || DEFAULT_IMS_HOST,
201
+ },
202
+ });
160
203
  return result;
161
204
  });
162
205
  },
@@ -26,12 +26,15 @@ const createNodeNetworkService = ({ headers: forwardedHeaders = {} } = {}) => {
26
26
  /**
27
27
  * @param {string} url
28
28
  * @param {string} body
29
+ * @param {Record<string, string>} [perCallHeaders] e.g. IMS auth headers
30
+ * for an authenticated Server API request. Can't override `Content-Type`.
29
31
  */
30
- const sendFetchRequest = (url, body) =>
32
+ const sendFetchRequest = (url, body, perCallHeaders) =>
31
33
  fetch(url, {
32
34
  method: "POST",
33
35
  headers: {
34
36
  ...forwardedHeaders,
37
+ ...perCallHeaders,
35
38
  "Content-Type": "text/plain; charset=UTF-8",
36
39
  },
37
40
  body,
@@ -13,6 +13,7 @@ governing permissions and limitations under the License.
13
13
  /** @import { NetworkService, StorageService, CookieService, RuntimeService, LegacyService, GlobalsService, PlatformServices } from "@adobe/alloy-core/services" */
14
14
  /** @import { Logger } from "@adobe/alloy-core/core/types.js" */
15
15
 
16
+ import packageJson from "../../package.json" with { type: "json" };
16
17
  import createNodeNetworkService from "./createNodeNetworkService.js";
17
18
  import createNodeStorageService from "./createNodeStorageService.js";
18
19
  import createNodeCookieService from "./createNodeCookieService.js";
@@ -49,7 +50,7 @@ import pickForwardableHeaders from "./pickForwardableHeaders.js";
49
50
  * Node is proxying an event for, if any — forwarded to Edge Network via
50
51
  * `pickForwardableHeaders` (unless `network` is overridden), and exposed
51
52
  * as-is on the returned object for the Context component to read.
52
- * @returns {PlatformServices & { request?: NodeRequestLike }}
53
+ * @returns {PlatformServices & { request?: NodeRequestLike, libraryVersionOverride: string }}
53
54
  */
54
55
  const createNodePlatformServices = ({
55
56
  network,
@@ -72,6 +73,9 @@ const createNodePlatformServices = ({
72
73
  legacy: legacy || createNodeLegacyService(),
73
74
  globals: globals || createNodeGlobalsService(),
74
75
  request,
76
+ // Node ships unbundled source, so the build-time __VERSION__ placeholder
77
+ // never gets replaced — report the real package.json version instead.
78
+ libraryVersionOverride: packageJson.version,
75
79
  });
76
80
 
77
81
  export default createNodePlatformServices;