@nodegx/preview 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28738,6 +28738,198 @@ var init_resolveBackend = __esm({
28738
28738
  }
28739
28739
  });
28740
28740
 
28741
+ // ../noodl-runtime/src/api/backends/SessionStore.ts
28742
+ function ambientStorage() {
28743
+ if (typeof localStorage === "undefined") return void 0;
28744
+ return localStorage;
28745
+ }
28746
+ function ambientBroadcaster() {
28747
+ if (typeof window === "undefined" || !window || typeof window.addEventListener !== "function") return void 0;
28748
+ return window;
28749
+ }
28750
+ function parseSessionKey(appId) {
28751
+ return "Parse/" + appId + "/currentUser";
28752
+ }
28753
+ function parseInstallationIdKey(appId) {
28754
+ return "Parse/" + appId + "/installationId";
28755
+ }
28756
+ function parseSessionStore(appId) {
28757
+ const cacheKey = String(appId);
28758
+ let store = _parseStores.get(cacheKey);
28759
+ if (store === void 0) {
28760
+ store = new SessionStore({ key: parseSessionKey(appId) });
28761
+ _parseStores.set(cacheKey, store);
28762
+ }
28763
+ return store;
28764
+ }
28765
+ var _tabCounter, SessionStore, _parseStores;
28766
+ var init_SessionStore = __esm({
28767
+ "../noodl-runtime/src/api/backends/SessionStore.ts"() {
28768
+ _tabCounter = 0;
28769
+ SessionStore = class {
28770
+ constructor(options) {
28771
+ this.key = options.key;
28772
+ this.lockKey = options.key + ".refresh-lock";
28773
+ this.injectedStorage = options.storage;
28774
+ this.injectedBroadcaster = options.broadcaster;
28775
+ this.lockTtlMs = options.lockTtlMs !== void 0 ? options.lockTtlMs : 1e4;
28776
+ this.now = options.now || Date.now;
28777
+ this.tabId = options.tabId || "tab-" + ++_tabCounter + "-" + Math.random().toString(36).slice(2);
28778
+ }
28779
+ /**
28780
+ * Resolved per access, not captured at construction.
28781
+ *
28782
+ * The code this replaced was a bare `localStorage[key]` evaluated on every
28783
+ * request, so a page (or a suite) that swapped the global took effect
28784
+ * immediately. Capturing it once would look identical until something did —
28785
+ * and something does: `parse-wire-adapter.test.ts` replaces
28786
+ * `globalThis.localStorage` between cases, and caching broke it. Found by
28787
+ * running the suite that was not this task's.
28788
+ */
28789
+ get storage() {
28790
+ if (this.injectedStorage !== void 0) return this.injectedStorage || void 0;
28791
+ return ambientStorage();
28792
+ }
28793
+ get broadcaster() {
28794
+ if (this.injectedBroadcaster !== void 0) return this.injectedBroadcaster || void 0;
28795
+ return ambientBroadcaster();
28796
+ }
28797
+ /** False under SSR and in the cloud runtime. Nothing here throws when it is. */
28798
+ get available() {
28799
+ return this.storage !== void 0;
28800
+ }
28801
+ // ── The session ──────────────────────────────────────────────────────────
28802
+ /** The stored text, or `undefined`. */
28803
+ readRaw() {
28804
+ const storage = this.storage;
28805
+ if (!storage) return void 0;
28806
+ const value2 = storage[this.key];
28807
+ return value2 === void 0 || value2 === null ? void 0 : String(value2);
28808
+ }
28809
+ /**
28810
+ * The stored session, or `undefined` when there is none *or it will not
28811
+ * parse*.
28812
+ *
28813
+ * Unparseable is treated as absent rather than raised, which is what all four
28814
+ * of the replaced call sites did — a corrupt entry must not stop an app from
28815
+ * loading, and the user can always sign in again.
28816
+ */
28817
+ read() {
28818
+ const raw = this.readRaw();
28819
+ if (raw === void 0) return void 0;
28820
+ try {
28821
+ return JSON.parse(raw);
28822
+ } catch (e) {
28823
+ return void 0;
28824
+ }
28825
+ }
28826
+ write(session) {
28827
+ const storage = this.storage;
28828
+ if (!storage) return;
28829
+ storage[this.key] = JSON.stringify(session);
28830
+ }
28831
+ clear() {
28832
+ const storage = this.storage;
28833
+ if (!storage) return;
28834
+ delete storage[this.key];
28835
+ }
28836
+ /**
28837
+ * Read and write any key in the same storage, with the same absent-storage
28838
+ * tolerance.
28839
+ *
28840
+ * Here for Parse's installation id, which lives beside the session under
28841
+ * `Parse/<appId>/installationId` and is the only other thing `userservice.ts`
28842
+ * kept in local storage. It is not session state, so it gets no method of its
28843
+ * own — but it does need the SSR guard, and duplicating that guard is how the
28844
+ * four copies above happened in the first place.
28845
+ */
28846
+ readKey(key) {
28847
+ const storage = this.storage;
28848
+ if (!storage) return void 0;
28849
+ const value2 = storage[key];
28850
+ return value2 === void 0 || value2 === null ? void 0 : String(value2);
28851
+ }
28852
+ writeKey(key, value2) {
28853
+ const storage = this.storage;
28854
+ if (!storage) return;
28855
+ storage[key] = value2;
28856
+ }
28857
+ // ── Cross-tab ────────────────────────────────────────────────────────────
28858
+ /**
28859
+ * Hear about session changes made by **other** tabs.
28860
+ *
28861
+ * The `storage` event fires only in tabs that did not make the change, which
28862
+ * is exactly the semantics wanted and is why this is preferred to
28863
+ * `BroadcastChannel`: no echo to filter out, no availability question, and it
28864
+ * comes free with the storage already in use.
28865
+ *
28866
+ * Returns an unsubscribe function. Returns a no-op one when there is no
28867
+ * broadcaster, so callers never branch.
28868
+ */
28869
+ onExternalChange(handler) {
28870
+ const broadcaster = this.broadcaster;
28871
+ if (!broadcaster) return () => {
28872
+ };
28873
+ const listener = (event) => {
28874
+ if (event && event.key !== null && event.key !== this.key) return;
28875
+ handler(this.read());
28876
+ };
28877
+ broadcaster.addEventListener("storage", listener);
28878
+ return () => broadcaster.removeEventListener("storage", listener);
28879
+ }
28880
+ /**
28881
+ * Try to become the tab that refreshes.
28882
+ *
28883
+ * Read, write, **read back**. `localStorage` writes are serialised per origin,
28884
+ * so in the racing case both tabs write and both read back and exactly one
28885
+ * sees its own id — and the loser discovers that *before issuing any request*,
28886
+ * because the whole sequence is synchronous with no `await` in it. That is
28887
+ * what keeps the cost of a lost race at one tab doing nothing rather than two
28888
+ * tabs spending the same rotating refresh token.
28889
+ *
28890
+ * It is not a mutex and BCN-006-LIFECYCLE-DESIGN §5 does not claim it is. The
28891
+ * residual risk is a same-millisecond interleaving across processes on an
28892
+ * implementation that does not serialise writes; the cost if it happens is one
28893
+ * wasted refresh, and the follower's wait-then-adopt behaviour means the user
28894
+ * still ends up with the session the winner wrote.
28895
+ *
28896
+ * **The lock expires.** A tab closed mid-refresh must not hold it forever —
28897
+ * that would be worse than having no lock at all.
28898
+ */
28899
+ tryAcquireRefreshLock() {
28900
+ const storage = this.storage;
28901
+ if (!storage) return false;
28902
+ const now = this.now();
28903
+ const existing = this.readLock();
28904
+ if (existing && existing.owner !== this.tabId && existing.expiresAt > now) return false;
28905
+ storage[this.lockKey] = JSON.stringify({ owner: this.tabId, expiresAt: now + this.lockTtlMs });
28906
+ const confirmed = this.readLock();
28907
+ return confirmed !== void 0 && confirmed.owner === this.tabId;
28908
+ }
28909
+ /** Release only our own lock — never one another tab has since taken. */
28910
+ releaseRefreshLock() {
28911
+ const storage = this.storage;
28912
+ if (!storage) return;
28913
+ const existing = this.readLock();
28914
+ if (existing && existing.owner !== this.tabId) return;
28915
+ delete storage[this.lockKey];
28916
+ }
28917
+ readLock() {
28918
+ const raw = this.readKey(this.lockKey);
28919
+ if (raw === void 0) return void 0;
28920
+ try {
28921
+ const parsed = JSON.parse(raw);
28922
+ if (!parsed || typeof parsed.owner !== "string" || typeof parsed.expiresAt !== "number") return void 0;
28923
+ return parsed;
28924
+ } catch (e) {
28925
+ return void 0;
28926
+ }
28927
+ }
28928
+ };
28929
+ _parseStores = /* @__PURE__ */ new Map();
28930
+ }
28931
+ });
28932
+
28741
28933
  // ../nodegx-backend-contract/src/realtime.ts
28742
28934
  function nextReconnectDelay(attempt, timing = REALTIME_TIMING) {
28743
28935
  const n = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
@@ -28898,6 +29090,7 @@ var DEFAULT_TIMING, DEFAULT_HOST, RealtimeSubscription;
28898
29090
  var init_RealtimeSubscription = __esm({
28899
29091
  "../noodl-runtime/src/api/backends/realtime/RealtimeSubscription.ts"() {
28900
29092
  init_resolveBackend_pure();
29093
+ init_SessionStore();
28901
29094
  init_realtime2();
28902
29095
  DEFAULT_TIMING = REALTIME_TIMING;
28903
29096
  DEFAULT_HOST = {};
@@ -29138,9 +29331,20 @@ var init_RealtimeSubscription = __esm({
29138
29331
  * fallback.
29139
29332
  */
29140
29333
  get token() {
29141
- if (isParseWireType(this.handle.type)) return this.handle.sessionToken || "";
29334
+ if (isParseWireType(this.handle.type)) return this.handle.sessionToken || this.storedSession() || "";
29142
29335
  return this.handle.sessionToken || this.handle.publicToken || "";
29143
29336
  }
29337
+ /**
29338
+ * 🔴 P121-TWO005-LIVEANON. The signed-in user's session, where a Parse-wire app keeps it: the session store, keyed by
29339
+ * the app id in `publicToken` — the same place `ParseWireAdapter` reads it from for every request
29340
+ * (`readStoredCurrentUser`). Nothing sets `handle.sessionToken` on this wire, so reading the handle alone made every
29341
+ * subscription anonymous for a signed-in member, and a collection only members may read refused it ("denies find
29342
+ * to anonymous") on every retry. SBR-011's arm passed a session ON THE HANDLE, which a real app never has.
29343
+ */
29344
+ storedSession() {
29345
+ const stored = parseSessionStore(this.handle.publicToken).read();
29346
+ return stored && typeof stored.sessionToken === "string" ? stored.sessionToken : "";
29347
+ }
29144
29348
  _armDeadline(generation2) {
29145
29349
  this._clearTimer("_deadlineTimer");
29146
29350
  this._deadlineTimer = this._setTimeout(() => {
@@ -29684,12 +29888,11 @@ var init_SseTransport = __esm({
29684
29888
  helloEvent: "connected",
29685
29889
  changeEvents: () => ["change"],
29686
29890
  resyncEvent: "resync",
29687
- subscribeRequest(base, token, clientId, subscriptions) {
29891
+ subscribeRequest(base, _token, clientId, subscriptions) {
29688
29892
  const body = subscriptions.map(
29689
29893
  (subscription) => subscription.where === void 0 ? { collection: subscription.collection } : { collection: subscription.collection, filter: subscription.where }
29690
29894
  );
29691
29895
  const headers = { "content-type": "application/json" };
29692
- if (token) headers["authorization"] = "Bearer " + token;
29693
29896
  return {
29694
29897
  url: base + "/realtime/subscriptions",
29695
29898
  init: {
@@ -31472,8 +31675,8 @@ var init_nodegx = __esm({
31472
31675
  // The header works. What it cannot do is make a file private on behalf of
31473
31676
  // nobody, and an author reading "supported" would not expect that.
31474
31677
  "files.private": degraded(
31475
- "A private upload is locked to the person who made it, so a file uploaded by anything other than a signed-in user \u2014 an admin tool, a cloud function \u2014 ends up private with no owner, and stays readable by everyone.",
31476
- "BCN-007 live, security enforced: uploaded as a signed-in user, the plain URL is 403 for anonymous AND for a second signed-in user. Uploaded with the master key, the identical request with X-NodeGX-File-Private: true serves 200 to anonymous \u2014 files.ts::upload sets owner=null for a non-user principal"
31678
+ "A private upload is locked to the person who made it, so a file uploaded by anything other than a signed-in user \u2014 an admin tool, a cloud function \u2014 ends up private with no owner, and stays readable by everyone. Upload File's Readers names roles that may read it too, such as role:member; a file with Readers is never readable by everyone.",
31679
+ "BCN-007 live, security enforced: uploaded as a signed-in user, the plain URL is 403 for anonymous AND for a second signed-in user. Uploaded with the master key, the identical request with X-NodeGX-File-Private: true serves 200 to anonymous \u2014 files.ts::upload sets owner=null for a non-user principal. P121-TWO001-MEMBERSPHOTO: X-NodeGX-File-Readers adds read-only role entries to the file's ACL; uploaded with the master key and role:crew, anonymous and a user without the role are 403 (files-http.test.ts)"
31477
31680
  ),
31478
31681
  "files.progress": supported("XHR upload path reports progress"),
31479
31682
  "auth.password": supported("nodegx-backend/src/server/users.ts"),
@@ -33207,198 +33410,6 @@ var init_AdapterEvents = __esm({
33207
33410
  }
33208
33411
  });
33209
33412
 
33210
- // ../noodl-runtime/src/api/backends/SessionStore.ts
33211
- function ambientStorage() {
33212
- if (typeof localStorage === "undefined") return void 0;
33213
- return localStorage;
33214
- }
33215
- function ambientBroadcaster() {
33216
- if (typeof window === "undefined" || !window || typeof window.addEventListener !== "function") return void 0;
33217
- return window;
33218
- }
33219
- function parseSessionKey(appId) {
33220
- return "Parse/" + appId + "/currentUser";
33221
- }
33222
- function parseInstallationIdKey(appId) {
33223
- return "Parse/" + appId + "/installationId";
33224
- }
33225
- function parseSessionStore(appId) {
33226
- const cacheKey = String(appId);
33227
- let store = _parseStores.get(cacheKey);
33228
- if (store === void 0) {
33229
- store = new SessionStore({ key: parseSessionKey(appId) });
33230
- _parseStores.set(cacheKey, store);
33231
- }
33232
- return store;
33233
- }
33234
- var _tabCounter, SessionStore, _parseStores;
33235
- var init_SessionStore = __esm({
33236
- "../noodl-runtime/src/api/backends/SessionStore.ts"() {
33237
- _tabCounter = 0;
33238
- SessionStore = class {
33239
- constructor(options) {
33240
- this.key = options.key;
33241
- this.lockKey = options.key + ".refresh-lock";
33242
- this.injectedStorage = options.storage;
33243
- this.injectedBroadcaster = options.broadcaster;
33244
- this.lockTtlMs = options.lockTtlMs !== void 0 ? options.lockTtlMs : 1e4;
33245
- this.now = options.now || Date.now;
33246
- this.tabId = options.tabId || "tab-" + ++_tabCounter + "-" + Math.random().toString(36).slice(2);
33247
- }
33248
- /**
33249
- * Resolved per access, not captured at construction.
33250
- *
33251
- * The code this replaced was a bare `localStorage[key]` evaluated on every
33252
- * request, so a page (or a suite) that swapped the global took effect
33253
- * immediately. Capturing it once would look identical until something did —
33254
- * and something does: `parse-wire-adapter.test.ts` replaces
33255
- * `globalThis.localStorage` between cases, and caching broke it. Found by
33256
- * running the suite that was not this task's.
33257
- */
33258
- get storage() {
33259
- if (this.injectedStorage !== void 0) return this.injectedStorage || void 0;
33260
- return ambientStorage();
33261
- }
33262
- get broadcaster() {
33263
- if (this.injectedBroadcaster !== void 0) return this.injectedBroadcaster || void 0;
33264
- return ambientBroadcaster();
33265
- }
33266
- /** False under SSR and in the cloud runtime. Nothing here throws when it is. */
33267
- get available() {
33268
- return this.storage !== void 0;
33269
- }
33270
- // ── The session ──────────────────────────────────────────────────────────
33271
- /** The stored text, or `undefined`. */
33272
- readRaw() {
33273
- const storage = this.storage;
33274
- if (!storage) return void 0;
33275
- const value2 = storage[this.key];
33276
- return value2 === void 0 || value2 === null ? void 0 : String(value2);
33277
- }
33278
- /**
33279
- * The stored session, or `undefined` when there is none *or it will not
33280
- * parse*.
33281
- *
33282
- * Unparseable is treated as absent rather than raised, which is what all four
33283
- * of the replaced call sites did — a corrupt entry must not stop an app from
33284
- * loading, and the user can always sign in again.
33285
- */
33286
- read() {
33287
- const raw = this.readRaw();
33288
- if (raw === void 0) return void 0;
33289
- try {
33290
- return JSON.parse(raw);
33291
- } catch (e) {
33292
- return void 0;
33293
- }
33294
- }
33295
- write(session) {
33296
- const storage = this.storage;
33297
- if (!storage) return;
33298
- storage[this.key] = JSON.stringify(session);
33299
- }
33300
- clear() {
33301
- const storage = this.storage;
33302
- if (!storage) return;
33303
- delete storage[this.key];
33304
- }
33305
- /**
33306
- * Read and write any key in the same storage, with the same absent-storage
33307
- * tolerance.
33308
- *
33309
- * Here for Parse's installation id, which lives beside the session under
33310
- * `Parse/<appId>/installationId` and is the only other thing `userservice.ts`
33311
- * kept in local storage. It is not session state, so it gets no method of its
33312
- * own — but it does need the SSR guard, and duplicating that guard is how the
33313
- * four copies above happened in the first place.
33314
- */
33315
- readKey(key) {
33316
- const storage = this.storage;
33317
- if (!storage) return void 0;
33318
- const value2 = storage[key];
33319
- return value2 === void 0 || value2 === null ? void 0 : String(value2);
33320
- }
33321
- writeKey(key, value2) {
33322
- const storage = this.storage;
33323
- if (!storage) return;
33324
- storage[key] = value2;
33325
- }
33326
- // ── Cross-tab ────────────────────────────────────────────────────────────
33327
- /**
33328
- * Hear about session changes made by **other** tabs.
33329
- *
33330
- * The `storage` event fires only in tabs that did not make the change, which
33331
- * is exactly the semantics wanted and is why this is preferred to
33332
- * `BroadcastChannel`: no echo to filter out, no availability question, and it
33333
- * comes free with the storage already in use.
33334
- *
33335
- * Returns an unsubscribe function. Returns a no-op one when there is no
33336
- * broadcaster, so callers never branch.
33337
- */
33338
- onExternalChange(handler) {
33339
- const broadcaster = this.broadcaster;
33340
- if (!broadcaster) return () => {
33341
- };
33342
- const listener = (event) => {
33343
- if (event && event.key !== null && event.key !== this.key) return;
33344
- handler(this.read());
33345
- };
33346
- broadcaster.addEventListener("storage", listener);
33347
- return () => broadcaster.removeEventListener("storage", listener);
33348
- }
33349
- /**
33350
- * Try to become the tab that refreshes.
33351
- *
33352
- * Read, write, **read back**. `localStorage` writes are serialised per origin,
33353
- * so in the racing case both tabs write and both read back and exactly one
33354
- * sees its own id — and the loser discovers that *before issuing any request*,
33355
- * because the whole sequence is synchronous with no `await` in it. That is
33356
- * what keeps the cost of a lost race at one tab doing nothing rather than two
33357
- * tabs spending the same rotating refresh token.
33358
- *
33359
- * It is not a mutex and BCN-006-LIFECYCLE-DESIGN §5 does not claim it is. The
33360
- * residual risk is a same-millisecond interleaving across processes on an
33361
- * implementation that does not serialise writes; the cost if it happens is one
33362
- * wasted refresh, and the follower's wait-then-adopt behaviour means the user
33363
- * still ends up with the session the winner wrote.
33364
- *
33365
- * **The lock expires.** A tab closed mid-refresh must not hold it forever —
33366
- * that would be worse than having no lock at all.
33367
- */
33368
- tryAcquireRefreshLock() {
33369
- const storage = this.storage;
33370
- if (!storage) return false;
33371
- const now = this.now();
33372
- const existing = this.readLock();
33373
- if (existing && existing.owner !== this.tabId && existing.expiresAt > now) return false;
33374
- storage[this.lockKey] = JSON.stringify({ owner: this.tabId, expiresAt: now + this.lockTtlMs });
33375
- const confirmed = this.readLock();
33376
- return confirmed !== void 0 && confirmed.owner === this.tabId;
33377
- }
33378
- /** Release only our own lock — never one another tab has since taken. */
33379
- releaseRefreshLock() {
33380
- const storage = this.storage;
33381
- if (!storage) return;
33382
- const existing = this.readLock();
33383
- if (existing && existing.owner !== this.tabId) return;
33384
- delete storage[this.lockKey];
33385
- }
33386
- readLock() {
33387
- const raw = this.readKey(this.lockKey);
33388
- if (raw === void 0) return void 0;
33389
- try {
33390
- const parsed = JSON.parse(raw);
33391
- if (!parsed || typeof parsed.owner !== "string" || typeof parsed.expiresAt !== "number") return void 0;
33392
- return parsed;
33393
- } catch (e) {
33394
- return void 0;
33395
- }
33396
- }
33397
- };
33398
- _parseStores = /* @__PURE__ */ new Map();
33399
- }
33400
- });
33401
-
33402
33413
  // ../noodl-runtime/src/api/backends/fileRef.ts
33403
33414
  function jwtExpiry(token) {
33404
33415
  if (typeof token !== "string") return void 0;
@@ -33532,6 +33543,13 @@ function withStatus(err, status) {
33532
33543
  if (status !== void 0 && status > 0) envelope.status = status;
33533
33544
  return envelope;
33534
33545
  }
33546
+ function uploadHeaders(options) {
33547
+ const headers = {};
33548
+ if (options.private) headers["X-NodeGX-File-Private"] = "true";
33549
+ const readers = typeof options.readers === "string" ? options.readers.trim() : "";
33550
+ if (readers) headers["X-NodeGX-File-Readers"] = readers;
33551
+ return Object.keys(headers).length ? headers : void 0;
33552
+ }
33535
33553
  function _removeProtectedFields(data, className) {
33536
33554
  const _data = Object.assign({}, data);
33537
33555
  _protectedFields._common.forEach((f) => delete _data[f]);
@@ -33949,10 +33967,10 @@ var init_ParseWireAdapter = __esm({
33949
33967
  content: options.file,
33950
33968
  // Dead — `_makeRequest` never reads `contentType`. See the module note.
33951
33969
  contentType: options.file.type,
33952
- // BAK-006 follow-up: the Upload File node's Private input. The backend
33953
- // reads this exact header (files.ts, case-insensitively) to ACL the
33954
- // upload to its owner instead of leaving it public.
33955
- headers: options.private ? { "X-NodeGX-File-Private": "true" } : void 0,
33970
+ // BAK-006 follow-up: the Upload File node's Private input, and (P121
33971
+ // MEMBERSPHOTO) its Readers. The backend reads these headers to ACL the
33972
+ // upload to its owner, and to those roles, instead of leaving it public.
33973
+ headers: uploadHeaders(options),
33956
33974
  // BCN-007 step 1. This used to be
33957
33975
  // `Object.assign({}, options.data, response)` cast to `{name, url}` —
33958
33976
  // whatever the backend sent, spread over a caller-supplied object no
@@ -95294,6 +95312,76 @@ var init_openfilepicker = __esm({
95294
95312
  }
95295
95313
  });
95296
95314
 
95315
+ // ../noodl-viewer-react/src/nodes/std-library/shrinkphoto.ts
95316
+ function shrunkSize(width, height, largestSide) {
95317
+ const longer = Math.max(width, height);
95318
+ if (!(largestSide > 0) || !(longer > largestSide)) return void 0;
95319
+ const scale = largestSide / longer;
95320
+ return {
95321
+ width: Math.max(1, Math.round(width * scale)),
95322
+ height: Math.max(1, Math.round(height * scale))
95323
+ };
95324
+ }
95325
+ function shrunkType(type) {
95326
+ return KEEPS_ITS_TYPE.has(type) ? type : "image/jpeg";
95327
+ }
95328
+ function isShrinkable(type) {
95329
+ return typeof type === "string" && type.startsWith("image/") && !LEFT_ALONE.has(type);
95330
+ }
95331
+ function shrunkName(name2, fromType, toType) {
95332
+ if (fromType === toType) return name2;
95333
+ const ext = toType === "image/jpeg" ? "jpg" : toType.slice("image/".length);
95334
+ const dot = name2.lastIndexOf(".");
95335
+ return `${dot > 0 ? name2.slice(0, dot) : name2 || "photo"}.${ext}`;
95336
+ }
95337
+ function qualityOf(quality) {
95338
+ const q = typeof quality === "number" && quality > 0 && quality <= 100 ? quality : DEFAULT_PHOTO_QUALITY;
95339
+ return q / 100;
95340
+ }
95341
+ async function shrinkPhoto(file, options, ops = browserPhotoOps) {
95342
+ if (!(options.largestSide > 0) || !isShrinkable(file.type)) return file;
95343
+ let photo;
95344
+ try {
95345
+ photo = await ops.decode(file);
95346
+ const size2 = shrunkSize(photo.width, photo.height, options.largestSide);
95347
+ if (!size2) return file;
95348
+ const type = shrunkType(file.type);
95349
+ const blob = await ops.encode(photo, size2.width, size2.height, type, qualityOf(options.quality));
95350
+ if (!blob || blob.size === 0 || blob.size >= file.size) return file;
95351
+ return new File([blob], shrunkName(file.name, file.type, type), {
95352
+ type,
95353
+ lastModified: file.lastModified
95354
+ });
95355
+ } catch (_e) {
95356
+ return file;
95357
+ } finally {
95358
+ if (photo && photo.close) photo.close();
95359
+ }
95360
+ }
95361
+ var DEFAULT_PHOTO_QUALITY, KEEPS_ITS_TYPE, LEFT_ALONE, browserPhotoOps;
95362
+ var init_shrinkphoto = __esm({
95363
+ "../noodl-viewer-react/src/nodes/std-library/shrinkphoto.ts"() {
95364
+ DEFAULT_PHOTO_QUALITY = 80;
95365
+ KEEPS_ITS_TYPE = /* @__PURE__ */ new Set(["image/png", "image/webp", "image/jpeg"]);
95366
+ LEFT_ALONE = /* @__PURE__ */ new Set(["image/gif", "image/svg+xml"]);
95367
+ browserPhotoOps = {
95368
+ async decode(file) {
95369
+ return createImageBitmap(file, { imageOrientation: "from-image" });
95370
+ },
95371
+ encode(photo, width, height, type, quality) {
95372
+ const canvas = document.createElement("canvas");
95373
+ canvas.width = width;
95374
+ canvas.height = height;
95375
+ const ctx = canvas.getContext("2d");
95376
+ if (!ctx) return Promise.resolve(null);
95377
+ ctx.imageSmoothingQuality = "high";
95378
+ ctx.drawImage(photo, 0, 0, width, height);
95379
+ return new Promise((resolve5) => canvas.toBlob(resolve5, type, quality));
95380
+ }
95381
+ };
95382
+ }
95383
+ });
95384
+
95297
95385
  // ../noodl-viewer-react/src/nodes/std-library/uploadfile.ts
95298
95386
  var uploadfile_exports = {};
95299
95387
  __export(uploadfile_exports, {
@@ -95314,6 +95402,7 @@ var init_uploadfile = __esm({
95314
95402
  init_schema_ports();
95315
95403
  init_fileRef();
95316
95404
  init_outcome();
95405
+ init_shrinkphoto();
95317
95406
  UPLOAD_ERROR_CODE = "upload-file/upload-failed";
95318
95407
  UploadFile = {
95319
95408
  name: "Upload File",
@@ -95352,6 +95441,42 @@ var init_uploadfile = __esm({
95352
95441
  this._internal.private = value2;
95353
95442
  }
95354
95443
  },
95444
+ // P121-TWO001-MEMBERSPHOTO: Private alone is the uploader's alone, and a feed shows every
95445
+ // member's photos to every other member. `X-NodeGX-File-Readers` shares a private file with
95446
+ // roles; the backend refuses a malformed list, or a role the uploader does not hold, in words.
95447
+ readers: {
95448
+ group: "General",
95449
+ displayName: "Readers",
95450
+ type: "string",
95451
+ description: "Roles that may read the file as well as whoever uploaded it, such as role:member, separated by commas; setting it makes the file private. NodeGX backends only",
95452
+ set(value2) {
95453
+ this._internal.readers = value2;
95454
+ }
95455
+ },
95456
+ // ── P121-TWO008-UPLOADSHRINK: Shrink Photos ──────────────────────────
95457
+ //
95458
+ // A phone photo is 3 to 8 MB, and the backend's thumbnails shrink what is served, not what
95459
+ // is sent. Off unless set, so every existing graph sends exactly what it sent before.
95460
+ shrinkTo: {
95461
+ group: "Shrink Photos",
95462
+ displayName: "Shrink To",
95463
+ type: "number",
95464
+ default: 0,
95465
+ description: "Makes a photo smaller in the browser before sending it, so its longer side is at most this many pixels; 0 sends every file as picked",
95466
+ set(value2) {
95467
+ this._internal.shrinkTo = value2;
95468
+ }
95469
+ },
95470
+ photoQuality: {
95471
+ group: "Shrink Photos",
95472
+ displayName: "Photo Quality",
95473
+ type: "number",
95474
+ default: 80,
95475
+ description: "How much detail a shrunk JPEG or WebP keeps, from 1 to 100; PNG keeps every pixel and its transparency, GIF and SVG are never shrunk",
95476
+ set(value2) {
95477
+ this._internal.photoQuality = value2;
95478
+ }
95479
+ },
95355
95480
  // ── BCN-007 step 3: File Location ────────────────────────────────────
95356
95481
  //
95357
95482
  // ⚠️ **Two of the five backends cannot store a file without being told
@@ -95429,10 +95554,11 @@ var init_uploadfile = __esm({
95429
95554
  }
95430
95555
  const store = this.cloudStore([outcome]);
95431
95556
  if (!store) return;
95432
- store.uploadFile({
95433
- file,
95557
+ const send = (sent) => store.uploadFile({
95558
+ file: sent,
95434
95559
  private: this._internal.private,
95435
- target: this.fileTarget(),
95560
+ readers: this._internal.readers,
95561
+ target: this.fileTarget(sent),
95436
95562
  onUploadProgress: (p) => {
95437
95563
  this._internal.progressTotal = p.total;
95438
95564
  this._internal.progressLoaded = p.loaded;
@@ -95448,14 +95574,20 @@ var init_uploadfile = __esm({
95448
95574
  // structurally rather than imported, because `cloudstore.js` is still
95449
95575
  // JavaScript and declares this callback `unknown` (PLAT-006 residual).
95450
95576
  success: (response) => {
95451
- const sent = file && typeof file.name === "string" ? file.name : void 0;
95452
- const filename = response.filename !== void 0 ? response.filename : sent;
95577
+ const sentName = sent && typeof sent.name === "string" ? sent.name : void 0;
95578
+ const filename = response.filename !== void 0 ? response.filename : sentName;
95453
95579
  this._internal.cloudFile = new import_cloudfile.default(filename !== void 0 ? Object.assign({}, response, { filename }) : response);
95454
95580
  this.flagOutputDirty("cloudFile");
95455
95581
  this.reportOutcome(outcome, "done");
95456
95582
  },
95457
95583
  error: (e) => this.setError(e, [outcome])
95458
95584
  });
95585
+ const shrinkTo = this._internal.shrinkTo;
95586
+ if (typeof shrinkTo === "number" && shrinkTo > 0 && isShrinkable(file.type)) {
95587
+ shrinkPhoto(file, { largestSide: shrinkTo, quality: this._internal.photoQuality }).then(send);
95588
+ } else {
95589
+ send(file);
95590
+ }
95459
95591
  });
95460
95592
  }
95461
95593
  }
@@ -95603,8 +95735,9 @@ var init_uploadfile = __esm({
95603
95735
  * meaningful: a project has one backend selected, and only one of the two
95604
95736
  * backends reads either group.
95605
95737
  */
95606
- fileTarget() {
95607
- const { bucket, path: path8, collection, recordId, field, file } = this._internal;
95738
+ fileTarget(sent) {
95739
+ const { bucket, path: path8, collection, recordId, field } = this._internal;
95740
+ const file = sent || this._internal.file;
95608
95741
  if (bucket) {
95609
95742
  return { kind: "bucket", bucket, path: path8 || file?.name || "" };
95610
95743
  }
@@ -111891,7 +112024,7 @@ var require_package = __commonJS({
111891
112024
  "package.json"(exports2, module2) {
111892
112025
  module2.exports = {
111893
112026
  name: "@nodegx/preview",
111894
- version: "0.2.1",
112027
+ version: "0.2.3",
111895
112028
  description: "Standalone live-preview harness: renders a NodeGX (OpenNoodl) v2 project in a browser with no editor process, and reloads it when the files on disk change.",
111896
112029
  license: "GPL-3.0-only",
111897
112030
  homepage: "https://nodegx.io",
@@ -111903,7 +112036,8 @@ var require_package = __commonJS({
111903
112036
  main: "src/index.ts",
111904
112037
  bin: {
111905
112038
  "noodl-preview": "./bin/noodl-preview.js",
111906
- "nodegx-deploy": "./bin/nodegx-deploy.js"
112039
+ "nodegx-deploy": "./bin/nodegx-deploy.js",
112040
+ preview: "./bin/noodl-preview.js"
111907
112041
  },
111908
112042
  files: [
111909
112043
  "bin",
@@ -149452,6 +149586,18 @@ var node_catalog_default = {
149452
149586
  isSignal: false,
149453
149587
  description: "Object path inside the bucket, such as avatars/me.png. Supabase only. Leave blank to use the file's own name"
149454
149588
  },
149589
+ {
149590
+ name: "photoQuality",
149591
+ displayName: "Photo Quality",
149592
+ group: "Shrink Photos",
149593
+ plug: "input",
149594
+ type: {
149595
+ name: "number"
149596
+ },
149597
+ isSignal: false,
149598
+ default: 80,
149599
+ description: "How much detail a shrunk JPEG or WebP keeps, from 1 to 100; PNG keeps every pixel and its transparency, GIF and SVG are never shrunk"
149600
+ },
149455
149601
  {
149456
149602
  name: "private",
149457
149603
  displayName: "Private",
@@ -149464,6 +149610,17 @@ var node_catalog_default = {
149464
149610
  default: false,
149465
149611
  description: "Restricts the stored file to whoever uploaded it, so reading it later needs a Sign File URL node"
149466
149612
  },
149613
+ {
149614
+ name: "readers",
149615
+ displayName: "Readers",
149616
+ group: "General",
149617
+ plug: "input",
149618
+ type: {
149619
+ name: "string"
149620
+ },
149621
+ isSignal: false,
149622
+ description: "Roles that may read the file as well as whoever uploaded it, such as role:member, separated by commas; setting it makes the file private. NodeGX backends only"
149623
+ },
149467
149624
  {
149468
149625
  name: "recordId",
149469
149626
  displayName: "Record ID (PocketBase)",
@@ -149475,6 +149632,18 @@ var node_catalog_default = {
149475
149632
  isSignal: false,
149476
149633
  description: "Existing record to attach the file to. PocketBase only. Leave blank to create a new record as part of the upload"
149477
149634
  },
149635
+ {
149636
+ name: "shrinkTo",
149637
+ displayName: "Shrink To",
149638
+ group: "Shrink Photos",
149639
+ plug: "input",
149640
+ type: {
149641
+ name: "number"
149642
+ },
149643
+ isSignal: false,
149644
+ default: 0,
149645
+ description: "Makes a photo smaller in the browser before sending it, so its longer side is at most this many pixels; 0 sends every file as picked"
149646
+ },
149478
149647
  {
149479
149648
  name: "upload",
149480
149649
  displayName: "Upload",
@@ -149617,6 +149786,15 @@ var node_catalog_default = {
149617
149786
  private: [
149618
149787
  "always: held as handed; read when the next Upload goes"
149619
149788
  ],
149789
+ readers: [
149790
+ "always: held as handed; read when the next Upload goes \u2014 set, those roles may read the file as well as whoever uploaded it, and the file is private"
149791
+ ],
149792
+ shrinkTo: [
149793
+ "always: held as handed; read when the next Upload goes \u2014 above 0, a photo (not a GIF or an SVG) is drawn smaller in the browser first, its longer side at most this many pixels, and the smaller file is what goes; it goes as picked when it is already that small, when the smaller one would weigh more, or when the browser cannot read it"
149794
+ ],
149795
+ photoQuality: [
149796
+ "always: held as handed; read when the next Upload goes, and only when a photo is shrunk \u2014 1 to 100 for a JPEG or WebP, anything else is 80; a PNG keeps every pixel"
149797
+ ],
149620
149798
  bucket: [
149621
149799
  "always: held as handed; read when the next Upload goes \u2014 set, the file goes to this bucket at Path (or under the file's own name)"
149622
149800
  ],
@@ -149633,7 +149811,7 @@ var node_catalog_default = {
149633
149811
  "always: held as handed; read when the next Upload goes, and only with Collection \u2014 a half-filled group is no location at all"
149634
149812
  ],
149635
149813
  upload: [
149636
- "always: its own upload at the frame's end \u2014 two presses are two uploads \u2014 with File, Private and the File Location held then: no File, or a Backend the project does not have, is Error, Error Status Code 0 and Failure at once; otherwise the backend answers with Cloud File then Done, or Error, Error Status Code and Failure"
149814
+ "always: its own upload at the frame's end \u2014 two presses are two uploads \u2014 with File, Private, Readers and the File Location held then: no File, or a Backend the project does not have, is Error, Error Status Code 0 and Failure at once; otherwise the backend answers with Cloud File then Done, or Error, Error Status Code and Failure"
149637
149815
  ]
149638
149816
  }
149639
149817
  },
@@ -189893,7 +190071,10 @@ function exportNode(node) {
189893
190071
  id: node.id,
189894
190072
  type: node.type.name,
189895
190073
  version: node.version,
189896
- variant: node.variant ? node.variant.name : void 0,
190074
+ // P123-S2-VARIANTLOST: `node.variant` is a lookup through the node library's registered modules, and a project
190075
+ // that is not `ProjectModel.instance` (the kit's in-page preview, the headless render) is not one — the name the
190076
+ // node wears is on the node itself, and the runtime looks the variant up by name.
190077
+ variant: node.variant ? node.variant.name : node.variantName || void 0,
189897
190078
  parameters: clone(node.parameters),
189898
190079
  stateParameters: node.stateParameters ? clone(node.stateParameters) : void 0,
189899
190080
  stateTransitions: node.stateTransitions ? clone(node.stateTransitions) : void 0,