@stardeck-customer-apps/testing 0.27.0 → 0.28.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/SKILL.md CHANGED
@@ -624,6 +624,24 @@ cursor, limit })`; `list()` intentionally keeps its previous array shape.
624
624
  `expect(app.schedules.earliestWake("booking-janitor")!.getTime()).toBeLessThanOrEqual(dueAt.getTime())`.
625
625
  `app.schedules.wakesFor(key)` lists them; `reset()` clears them. Run the job's handler
626
626
  yourself to test what it does and the `nextRun()` it returns.
627
+ - `getControl` / `getControls` / `setControl` from `@stardeck-customer-apps/controls/server` — the
628
+ sync (`POST /api/deployments/:id/controls`) and write (`PUT .../controls/:key`) routes are
629
+ simulated, signature-checked like the others, and served from `app.controls`. Defaults are
630
+ permissive so existing tests keep passing: `flag.paymentsSetupStep: false`,
631
+ `plan.locations: { included: 3, max: null, extraMonthly: { amount: 990, currency: "THB" } }`,
632
+ `plan.multiEntity: true`, `payments.provider: "beam"`, `payments.methods: { card: true, promptPay: true }`.
633
+ Seed with `createTestApp({ controls: { "plan.multiEntity": false } })`. Then
634
+ `app.controls.set(key, value)` (the next `getControl` in the same process sees it: the
635
+ controls cache is dropped on every change and on `reset()`),
636
+ `app.controls.setUnavailable(key)` (that key reads `{ ok: false }`, like a platform outage),
637
+ `app.controls.lastUsage()` (the usage the app reported on its latest sync, for example
638
+ `{ locations: 4 }`), `app.controls.writes()` (each `setControl` the app made:
639
+ `{ key, value, userToken }`) and `app.controls.refuseNextWrite({ code, message, connectUrl? })`
640
+ (the next write fails once with that error, for example `PROVIDER_NOT_READY` with a connect
641
+ link). A write needs the person's session token in `X-Stardeck-User-Token`; without it the
642
+ simulator answers 401 `UNAUTHENTICATED`. Only `payments.provider` and `payments.methods` are
643
+ writable (anything else is 400 `NOT_WRITABLE`), and an invalid value is 400 `INVALID_VALUE`.
644
+ `app.controls.clear()` returns to the seeded values. Test the `ok: false` path of every gate.
627
645
 
628
646
  ### Receipt layout and printer capabilities
629
647
 
package/dist/index.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as _stardeck_customer_apps_payments_sdk from '@stardeck-customer-apps/payments-sdk';
2
2
  import * as next_server from 'next/server';
3
+ import { ControlKey, ControlValue, ControlsUsage, ControlWriteErrorCode } from '@stardeck-customer-apps/controls';
3
4
  import { PGlite } from '@electric-sql/pglite';
4
5
  import { T as TestDataStoreInput } from './data-store-manifest-Zwgcv0DJ.mjs';
5
6
  export { S as STARDECK_DATA_STORES_ENV, a as TestDataStoreManifestEntry, b as buildTestDataStoreManifest } from './data-store-manifest-Zwgcv0DJ.mjs';
@@ -745,7 +746,40 @@ interface TestSchedules {
745
746
  earliestWake(scheduleKey: string): Date | undefined;
746
747
  clear(): void;
747
748
  }
749
+ interface CapturedControlWrite {
750
+ key: ControlKey;
751
+ value: unknown;
752
+ /** The person's session token the app sent in `X-Stardeck-User-Token`. */
753
+ userToken: string;
754
+ }
755
+ interface ControlRefusal {
756
+ code: ControlWriteErrorCode;
757
+ message: string;
758
+ connectUrl?: string;
759
+ }
760
+ /** The simulated app-controls sync and write routes (`@stardeck-customer-apps/controls`). */
761
+ interface TestControls {
762
+ /**
763
+ * Set a control's value (the app sees it on its next `getControl`, with no
764
+ * stale cache). Also makes a key that was forced unavailable available again.
765
+ */
766
+ set<K extends ControlKey>(key: K, value: ControlValue<K>): void;
767
+ /** Make the sync report this control as `{ ok: false }`, like a platform outage for one key. */
768
+ setUnavailable(key: ControlKey): void;
769
+ /** The usage the app reported on its latest sync, or null if it reported none. */
770
+ lastUsage(): ControlsUsage | null;
771
+ /** Writes the app made through `setControl`, oldest first. */
772
+ writes(): CapturedControlWrite[];
773
+ /** The next write is refused with this error, then writes succeed again. */
774
+ refuseNextWrite(refusal: ControlRefusal): void;
775
+ /** Back to the values the test app was created with; clears writes, usage and refusals. */
776
+ clear(): void;
777
+ }
748
778
  interface TestAppOptions {
779
+ /** Control values the simulated platform serves; unlisted keys keep the permissive defaults. */
780
+ controls?: Partial<{
781
+ [K in ControlKey]: ControlValue<K>;
782
+ }>;
749
783
  /**
750
784
  * The LIFF ID the platform forwards to the app for a LINE MINI App shop. It is
751
785
  * what `GET /api/auth/me` returns as `liffId` and what makes `useLiff()` start
@@ -799,6 +833,8 @@ interface TestApp {
799
833
  starlens: TestStarlens;
800
834
  /** Wakes the app asked of its adaptive schedules (`wakeSchedule`). */
801
835
  schedules: TestSchedules;
836
+ /** Platform-owned app controls: flags, plan limits, payment provider and methods. */
837
+ controls: TestControls;
802
838
  /** Convenience for raw SQL: `app.query("SELECT ...", [param])`. */
803
839
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
804
840
  /**
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as _stardeck_customer_apps_payments_sdk from '@stardeck-customer-apps/payments-sdk';
2
2
  import * as next_server from 'next/server';
3
+ import { ControlKey, ControlValue, ControlsUsage, ControlWriteErrorCode } from '@stardeck-customer-apps/controls';
3
4
  import { PGlite } from '@electric-sql/pglite';
4
5
  import { T as TestDataStoreInput } from './data-store-manifest-Zwgcv0DJ.js';
5
6
  export { S as STARDECK_DATA_STORES_ENV, a as TestDataStoreManifestEntry, b as buildTestDataStoreManifest } from './data-store-manifest-Zwgcv0DJ.js';
@@ -745,7 +746,40 @@ interface TestSchedules {
745
746
  earliestWake(scheduleKey: string): Date | undefined;
746
747
  clear(): void;
747
748
  }
749
+ interface CapturedControlWrite {
750
+ key: ControlKey;
751
+ value: unknown;
752
+ /** The person's session token the app sent in `X-Stardeck-User-Token`. */
753
+ userToken: string;
754
+ }
755
+ interface ControlRefusal {
756
+ code: ControlWriteErrorCode;
757
+ message: string;
758
+ connectUrl?: string;
759
+ }
760
+ /** The simulated app-controls sync and write routes (`@stardeck-customer-apps/controls`). */
761
+ interface TestControls {
762
+ /**
763
+ * Set a control's value (the app sees it on its next `getControl`, with no
764
+ * stale cache). Also makes a key that was forced unavailable available again.
765
+ */
766
+ set<K extends ControlKey>(key: K, value: ControlValue<K>): void;
767
+ /** Make the sync report this control as `{ ok: false }`, like a platform outage for one key. */
768
+ setUnavailable(key: ControlKey): void;
769
+ /** The usage the app reported on its latest sync, or null if it reported none. */
770
+ lastUsage(): ControlsUsage | null;
771
+ /** Writes the app made through `setControl`, oldest first. */
772
+ writes(): CapturedControlWrite[];
773
+ /** The next write is refused with this error, then writes succeed again. */
774
+ refuseNextWrite(refusal: ControlRefusal): void;
775
+ /** Back to the values the test app was created with; clears writes, usage and refusals. */
776
+ clear(): void;
777
+ }
748
778
  interface TestAppOptions {
779
+ /** Control values the simulated platform serves; unlisted keys keep the permissive defaults. */
780
+ controls?: Partial<{
781
+ [K in ControlKey]: ControlValue<K>;
782
+ }>;
749
783
  /**
750
784
  * The LIFF ID the platform forwards to the app for a LINE MINI App shop. It is
751
785
  * what `GET /api/auth/me` returns as `liffId` and what makes `useLiff()` start
@@ -799,6 +833,8 @@ interface TestApp {
799
833
  starlens: TestStarlens;
800
834
  /** Wakes the app asked of its adaptive schedules (`wakeSchedule`). */
801
835
  schedules: TestSchedules;
836
+ /** Platform-owned app controls: flags, plan limits, payment provider and methods. */
837
+ controls: TestControls;
802
838
  /** Convenience for raw SQL: `app.query("SELECT ...", [param])`. */
803
839
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
804
840
  /**
package/dist/index.js CHANGED
@@ -381,6 +381,14 @@ var state = globalSingleton("state", () => ({
381
381
  starlensCalls: [],
382
382
  starlensReplies: [],
383
383
  starlensDefaultReply: null,
384
+ controls: {
385
+ seed: {},
386
+ values: {},
387
+ unavailable: /* @__PURE__ */ new Set(),
388
+ lastUsage: null,
389
+ writes: [],
390
+ refusal: null
391
+ },
384
392
  allowNetwork: false
385
393
  }));
386
394
  function requireDb() {
@@ -4024,6 +4032,141 @@ function createEdge() {
4024
4032
  };
4025
4033
  }
4026
4034
 
4035
+ // src/simulator/controls.ts
4036
+ var CONTROLS_STATE_SYMBOL = /* @__PURE__ */ Symbol.for("stardeck.controls.state");
4037
+ var USER_TOKEN_HEADER = "x-stardeck-user-token";
4038
+ var DEFAULTS = {
4039
+ "flag.paymentsSetupStep": false,
4040
+ "plan.locations": { included: 3, max: null, extraMonthly: { amount: 990, currency: "THB" } },
4041
+ "plan.multiEntity": true,
4042
+ "payments.provider": "beam",
4043
+ "payments.methods": { card: true, promptPay: true }
4044
+ };
4045
+ var REFUSAL_STATUS = {
4046
+ NOT_WRITABLE: 400,
4047
+ INVALID_VALUE: 400,
4048
+ UNAUTHENTICATED: 401,
4049
+ FORBIDDEN: 403,
4050
+ WRITE_NOT_ALLOWED_HERE: 403,
4051
+ PROVIDER_NOT_READY: 409,
4052
+ UNAVAILABLE: 503
4053
+ };
4054
+ var controlsModule = null;
4055
+ function loadControls() {
4056
+ controlsModule ??= import("@stardeck-customer-apps/controls");
4057
+ return controlsModule;
4058
+ }
4059
+ function resetControlsClientCache() {
4060
+ const clientState = globalThis[CONTROLS_STATE_SYMBOL];
4061
+ clientState?.reset?.();
4062
+ }
4063
+ function initControls(seed) {
4064
+ state.controls.seed = { ...seed };
4065
+ restoreControls();
4066
+ }
4067
+ function restoreControls() {
4068
+ state.controls.values = { ...DEFAULTS, ...state.controls.seed };
4069
+ state.controls.unavailable = /* @__PURE__ */ new Set();
4070
+ state.controls.lastUsage = null;
4071
+ state.controls.writes = [];
4072
+ state.controls.refusal = null;
4073
+ resetControlsClientCache();
4074
+ }
4075
+ function clearControls() {
4076
+ restoreControls();
4077
+ }
4078
+ function currentControlsBody() {
4079
+ const controls = {};
4080
+ for (const [key, value] of Object.entries(state.controls.values)) {
4081
+ controls[key] = state.controls.unavailable.has(key) ? { ok: false, reason: "unavailable (simulated)" } : { ok: true, value };
4082
+ }
4083
+ return controls;
4084
+ }
4085
+ function refusalResponse(refusal) {
4086
+ return json({ error: refusal }, REFUSAL_STATUS[refusal.code]);
4087
+ }
4088
+ function handleSync(body) {
4089
+ const usage = body.usage;
4090
+ if (typeof usage === "object" && usage !== null) {
4091
+ const locations = usage.locations;
4092
+ state.controls.lastUsage = typeof locations === "number" && Number.isInteger(locations) && locations >= 0 ? { locations } : {};
4093
+ }
4094
+ return json({ controls: currentControlsBody() });
4095
+ }
4096
+ async function handleWrite(request, key) {
4097
+ const { CONTROLS, isWritableControl } = await loadControls();
4098
+ if (!isWritableControl(key)) {
4099
+ return refusalResponse({
4100
+ code: "NOT_WRITABLE",
4101
+ message: `${key} cannot be changed from the app`
4102
+ });
4103
+ }
4104
+ const userToken = request.headers.get(USER_TOKEN_HEADER);
4105
+ if (!userToken) {
4106
+ return refusalResponse({
4107
+ code: "UNAUTHENTICATED",
4108
+ message: "A signed-in person is required (X-Stardeck-User-Token)"
4109
+ });
4110
+ }
4111
+ const body = await readJsonBody(request);
4112
+ const parsed = CONTROLS[key].schema.safeParse(body.value);
4113
+ if (!parsed.success) {
4114
+ return refusalResponse({
4115
+ code: "INVALID_VALUE",
4116
+ message: parsed.error.issues[0]?.message ?? "Invalid value"
4117
+ });
4118
+ }
4119
+ const refusal = state.controls.refusal;
4120
+ if (refusal) {
4121
+ state.controls.refusal = null;
4122
+ return refusalResponse(refusal);
4123
+ }
4124
+ state.controls.values[key] = parsed.data;
4125
+ state.controls.unavailable.delete(key);
4126
+ state.controls.writes.push({ key, value: parsed.data, userToken });
4127
+ return json({ controls: currentControlsBody() });
4128
+ }
4129
+ var CONTROLS_PATH = /^\/api\/deployments\/[^/]+\/controls(?:\/([^/]+))?$/;
4130
+ async function handleControlsRequest(request, url) {
4131
+ const match = url.pathname.match(CONTROLS_PATH);
4132
+ const key = match?.[1] ? decodeURIComponent(match[1]) : null;
4133
+ if (!key && request.method === "POST") {
4134
+ return handleSync(await readJsonBody(request));
4135
+ }
4136
+ if (key && request.method === "PUT") {
4137
+ return handleWrite(request, key);
4138
+ }
4139
+ return json(
4140
+ {
4141
+ error: { code: "UNAVAILABLE", message: `No simulator for ${request.method} ${url.pathname}` }
4142
+ },
4143
+ 404
4144
+ );
4145
+ }
4146
+ function createControls() {
4147
+ return {
4148
+ set(key, value) {
4149
+ state.controls.values[key] = value;
4150
+ state.controls.unavailable.delete(key);
4151
+ resetControlsClientCache();
4152
+ },
4153
+ setUnavailable(key) {
4154
+ state.controls.unavailable.add(key);
4155
+ resetControlsClientCache();
4156
+ },
4157
+ lastUsage() {
4158
+ return state.controls.lastUsage;
4159
+ },
4160
+ writes() {
4161
+ return state.controls.writes;
4162
+ },
4163
+ refuseNextWrite(refusal) {
4164
+ state.controls.refusal = refusal;
4165
+ },
4166
+ clear: restoreControls
4167
+ };
4168
+ }
4169
+
4027
4170
  // src/simulator/schedules.ts
4028
4171
  async function handleScheduleWake(request, scheduleKey) {
4029
4172
  if (request.method !== "POST") return failure(`Method ${request.method} not allowed`, 405);
@@ -4215,8 +4358,10 @@ function requiresDeploymentHmac(request, url) {
4215
4358
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
4216
4359
  const isStarlens = url.pathname === "/api/starlens/extract";
4217
4360
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
4218
- return !!(isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
4361
+ const controlsMatch = url.pathname.match(CONTROLS_PATH2);
4362
+ return !!(controlsMatch || isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
4219
4363
  }
4364
+ var CONTROLS_PATH2 = /^\/api\/deployments\/[^/]+\/controls(\/[^/]+)?$/;
4220
4365
  var SCHEDULE_WAKE_PATH = /^\/api\/deployments\/[^/]+\/schedules\/([^/]+)\/wake$/;
4221
4366
  function deploymentIdFromPath(url) {
4222
4367
  const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
@@ -4284,6 +4429,9 @@ async function handleSimulatedRequest(request, url) {
4284
4429
  if (edgeMatch) {
4285
4430
  return handleEdgeRequest(request, edgeMatch[1] ?? "");
4286
4431
  }
4432
+ if (CONTROLS_PATH2.test(url.pathname)) {
4433
+ return handleControlsRequest(request, url);
4434
+ }
4287
4435
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
4288
4436
  if (scheduleWakeMatch) {
4289
4437
  return handleScheduleWake(request, decodeURIComponent(scheduleWakeMatch[1]));
@@ -4312,7 +4460,7 @@ async function handleSimulatedRequest(request, url) {
4312
4460
  }
4313
4461
  }
4314
4462
  return failure(
4315
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, edge print/display/bindings, starlens extract, schedule wakes, auth verify/refresh, Neon /sql.`,
4463
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, edge print/display/bindings, app controls sync/write, starlens extract, schedule wakes, auth verify/refresh, Neon /sql.`,
4316
4464
  404
4317
4465
  );
4318
4466
  }
@@ -4461,6 +4609,7 @@ async function createTestApp(options = {}) {
4461
4609
  const initialLiffId = options.liffId ?? null;
4462
4610
  state.liffId = initialLiffId;
4463
4611
  installFetchRouter();
4612
+ initControls(options.controls);
4464
4613
  const schemaSql = loadSchemaSql(options) ?? "";
4465
4614
  const db = await bootWithSchema(schemaSql);
4466
4615
  state.db = db;
@@ -4492,6 +4641,7 @@ async function createTestApp(options = {}) {
4492
4641
  const edge = createEdge();
4493
4642
  const starlens = createStarlens();
4494
4643
  const schedules = createSchedules();
4644
+ const controls = createControls();
4495
4645
  const app = {
4496
4646
  db,
4497
4647
  inbox,
@@ -4502,6 +4652,7 @@ async function createTestApp(options = {}) {
4502
4652
  edge,
4503
4653
  starlens,
4504
4654
  schedules,
4655
+ controls,
4505
4656
  async query(sql, params = []) {
4506
4657
  const result = await db.query(sql, params);
4507
4658
  return result.rows;
@@ -4551,6 +4702,7 @@ async function createTestApp(options = {}) {
4551
4702
  edge.clear();
4552
4703
  starlens.clear();
4553
4704
  schedules.clear();
4705
+ clearControls();
4554
4706
  },
4555
4707
  async close() {
4556
4708
  state.db = null;
@@ -4567,6 +4719,7 @@ async function createTestApp(options = {}) {
4567
4719
  edge.clear();
4568
4720
  starlens.clear();
4569
4721
  schedules.clear();
4722
+ clearControls();
4570
4723
  uninstallFetchRouter();
4571
4724
  await db.close();
4572
4725
  }
package/dist/index.mjs CHANGED
@@ -342,6 +342,14 @@ var state = globalSingleton("state", () => ({
342
342
  starlensCalls: [],
343
343
  starlensReplies: [],
344
344
  starlensDefaultReply: null,
345
+ controls: {
346
+ seed: {},
347
+ values: {},
348
+ unavailable: /* @__PURE__ */ new Set(),
349
+ lastUsage: null,
350
+ writes: [],
351
+ refusal: null
352
+ },
345
353
  allowNetwork: false
346
354
  }));
347
355
  function requireDb() {
@@ -3985,6 +3993,141 @@ function createEdge() {
3985
3993
  };
3986
3994
  }
3987
3995
 
3996
+ // src/simulator/controls.ts
3997
+ var CONTROLS_STATE_SYMBOL = /* @__PURE__ */ Symbol.for("stardeck.controls.state");
3998
+ var USER_TOKEN_HEADER = "x-stardeck-user-token";
3999
+ var DEFAULTS = {
4000
+ "flag.paymentsSetupStep": false,
4001
+ "plan.locations": { included: 3, max: null, extraMonthly: { amount: 990, currency: "THB" } },
4002
+ "plan.multiEntity": true,
4003
+ "payments.provider": "beam",
4004
+ "payments.methods": { card: true, promptPay: true }
4005
+ };
4006
+ var REFUSAL_STATUS = {
4007
+ NOT_WRITABLE: 400,
4008
+ INVALID_VALUE: 400,
4009
+ UNAUTHENTICATED: 401,
4010
+ FORBIDDEN: 403,
4011
+ WRITE_NOT_ALLOWED_HERE: 403,
4012
+ PROVIDER_NOT_READY: 409,
4013
+ UNAVAILABLE: 503
4014
+ };
4015
+ var controlsModule = null;
4016
+ function loadControls() {
4017
+ controlsModule ??= import("@stardeck-customer-apps/controls");
4018
+ return controlsModule;
4019
+ }
4020
+ function resetControlsClientCache() {
4021
+ const clientState = globalThis[CONTROLS_STATE_SYMBOL];
4022
+ clientState?.reset?.();
4023
+ }
4024
+ function initControls(seed) {
4025
+ state.controls.seed = { ...seed };
4026
+ restoreControls();
4027
+ }
4028
+ function restoreControls() {
4029
+ state.controls.values = { ...DEFAULTS, ...state.controls.seed };
4030
+ state.controls.unavailable = /* @__PURE__ */ new Set();
4031
+ state.controls.lastUsage = null;
4032
+ state.controls.writes = [];
4033
+ state.controls.refusal = null;
4034
+ resetControlsClientCache();
4035
+ }
4036
+ function clearControls() {
4037
+ restoreControls();
4038
+ }
4039
+ function currentControlsBody() {
4040
+ const controls = {};
4041
+ for (const [key, value] of Object.entries(state.controls.values)) {
4042
+ controls[key] = state.controls.unavailable.has(key) ? { ok: false, reason: "unavailable (simulated)" } : { ok: true, value };
4043
+ }
4044
+ return controls;
4045
+ }
4046
+ function refusalResponse(refusal) {
4047
+ return json({ error: refusal }, REFUSAL_STATUS[refusal.code]);
4048
+ }
4049
+ function handleSync(body) {
4050
+ const usage = body.usage;
4051
+ if (typeof usage === "object" && usage !== null) {
4052
+ const locations = usage.locations;
4053
+ state.controls.lastUsage = typeof locations === "number" && Number.isInteger(locations) && locations >= 0 ? { locations } : {};
4054
+ }
4055
+ return json({ controls: currentControlsBody() });
4056
+ }
4057
+ async function handleWrite(request, key) {
4058
+ const { CONTROLS, isWritableControl } = await loadControls();
4059
+ if (!isWritableControl(key)) {
4060
+ return refusalResponse({
4061
+ code: "NOT_WRITABLE",
4062
+ message: `${key} cannot be changed from the app`
4063
+ });
4064
+ }
4065
+ const userToken = request.headers.get(USER_TOKEN_HEADER);
4066
+ if (!userToken) {
4067
+ return refusalResponse({
4068
+ code: "UNAUTHENTICATED",
4069
+ message: "A signed-in person is required (X-Stardeck-User-Token)"
4070
+ });
4071
+ }
4072
+ const body = await readJsonBody(request);
4073
+ const parsed = CONTROLS[key].schema.safeParse(body.value);
4074
+ if (!parsed.success) {
4075
+ return refusalResponse({
4076
+ code: "INVALID_VALUE",
4077
+ message: parsed.error.issues[0]?.message ?? "Invalid value"
4078
+ });
4079
+ }
4080
+ const refusal = state.controls.refusal;
4081
+ if (refusal) {
4082
+ state.controls.refusal = null;
4083
+ return refusalResponse(refusal);
4084
+ }
4085
+ state.controls.values[key] = parsed.data;
4086
+ state.controls.unavailable.delete(key);
4087
+ state.controls.writes.push({ key, value: parsed.data, userToken });
4088
+ return json({ controls: currentControlsBody() });
4089
+ }
4090
+ var CONTROLS_PATH = /^\/api\/deployments\/[^/]+\/controls(?:\/([^/]+))?$/;
4091
+ async function handleControlsRequest(request, url) {
4092
+ const match = url.pathname.match(CONTROLS_PATH);
4093
+ const key = match?.[1] ? decodeURIComponent(match[1]) : null;
4094
+ if (!key && request.method === "POST") {
4095
+ return handleSync(await readJsonBody(request));
4096
+ }
4097
+ if (key && request.method === "PUT") {
4098
+ return handleWrite(request, key);
4099
+ }
4100
+ return json(
4101
+ {
4102
+ error: { code: "UNAVAILABLE", message: `No simulator for ${request.method} ${url.pathname}` }
4103
+ },
4104
+ 404
4105
+ );
4106
+ }
4107
+ function createControls() {
4108
+ return {
4109
+ set(key, value) {
4110
+ state.controls.values[key] = value;
4111
+ state.controls.unavailable.delete(key);
4112
+ resetControlsClientCache();
4113
+ },
4114
+ setUnavailable(key) {
4115
+ state.controls.unavailable.add(key);
4116
+ resetControlsClientCache();
4117
+ },
4118
+ lastUsage() {
4119
+ return state.controls.lastUsage;
4120
+ },
4121
+ writes() {
4122
+ return state.controls.writes;
4123
+ },
4124
+ refuseNextWrite(refusal) {
4125
+ state.controls.refusal = refusal;
4126
+ },
4127
+ clear: restoreControls
4128
+ };
4129
+ }
4130
+
3988
4131
  // src/simulator/schedules.ts
3989
4132
  async function handleScheduleWake(request, scheduleKey) {
3990
4133
  if (request.method !== "POST") return failure(`Method ${request.method} not allowed`, 405);
@@ -4176,8 +4319,10 @@ function requiresDeploymentHmac(request, url) {
4176
4319
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
4177
4320
  const isStarlens = url.pathname === "/api/starlens/extract";
4178
4321
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
4179
- return !!(isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
4322
+ const controlsMatch = url.pathname.match(CONTROLS_PATH2);
4323
+ return !!(controlsMatch || isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
4180
4324
  }
4325
+ var CONTROLS_PATH2 = /^\/api\/deployments\/[^/]+\/controls(\/[^/]+)?$/;
4181
4326
  var SCHEDULE_WAKE_PATH = /^\/api\/deployments\/[^/]+\/schedules\/([^/]+)\/wake$/;
4182
4327
  function deploymentIdFromPath(url) {
4183
4328
  const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
@@ -4245,6 +4390,9 @@ async function handleSimulatedRequest(request, url) {
4245
4390
  if (edgeMatch) {
4246
4391
  return handleEdgeRequest(request, edgeMatch[1] ?? "");
4247
4392
  }
4393
+ if (CONTROLS_PATH2.test(url.pathname)) {
4394
+ return handleControlsRequest(request, url);
4395
+ }
4248
4396
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
4249
4397
  if (scheduleWakeMatch) {
4250
4398
  return handleScheduleWake(request, decodeURIComponent(scheduleWakeMatch[1]));
@@ -4273,7 +4421,7 @@ async function handleSimulatedRequest(request, url) {
4273
4421
  }
4274
4422
  }
4275
4423
  return failure(
4276
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, edge print/display/bindings, starlens extract, schedule wakes, auth verify/refresh, Neon /sql.`,
4424
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, edge print/display/bindings, app controls sync/write, starlens extract, schedule wakes, auth verify/refresh, Neon /sql.`,
4277
4425
  404
4278
4426
  );
4279
4427
  }
@@ -4422,6 +4570,7 @@ async function createTestApp(options = {}) {
4422
4570
  const initialLiffId = options.liffId ?? null;
4423
4571
  state.liffId = initialLiffId;
4424
4572
  installFetchRouter();
4573
+ initControls(options.controls);
4425
4574
  const schemaSql = loadSchemaSql(options) ?? "";
4426
4575
  const db = await bootWithSchema(schemaSql);
4427
4576
  state.db = db;
@@ -4453,6 +4602,7 @@ async function createTestApp(options = {}) {
4453
4602
  const edge = createEdge();
4454
4603
  const starlens = createStarlens();
4455
4604
  const schedules = createSchedules();
4605
+ const controls = createControls();
4456
4606
  const app = {
4457
4607
  db,
4458
4608
  inbox,
@@ -4463,6 +4613,7 @@ async function createTestApp(options = {}) {
4463
4613
  edge,
4464
4614
  starlens,
4465
4615
  schedules,
4616
+ controls,
4466
4617
  async query(sql, params = []) {
4467
4618
  const result = await db.query(sql, params);
4468
4619
  return result.rows;
@@ -4512,6 +4663,7 @@ async function createTestApp(options = {}) {
4512
4663
  edge.clear();
4513
4664
  starlens.clear();
4514
4665
  schedules.clear();
4666
+ clearControls();
4515
4667
  },
4516
4668
  async close() {
4517
4669
  state.db = null;
@@ -4528,6 +4680,7 @@ async function createTestApp(options = {}) {
4528
4680
  edge.clear();
4529
4681
  starlens.clear();
4530
4682
  schedules.clear();
4683
+ clearControls();
4531
4684
  uninstallFetchRouter();
4532
4685
  await db.close();
4533
4686
  }
@@ -103,6 +103,14 @@ var state = globalSingleton("state", () => ({
103
103
  starlensCalls: [],
104
104
  starlensReplies: [],
105
105
  starlensDefaultReply: null,
106
+ controls: {
107
+ seed: {},
108
+ values: {},
109
+ unavailable: /* @__PURE__ */ new Set(),
110
+ lastUsage: null,
111
+ writes: [],
112
+ refusal: null
113
+ },
106
114
  allowNetwork: false
107
115
  }));
108
116
 
@@ -76,6 +76,14 @@ var state = globalSingleton("state", () => ({
76
76
  starlensCalls: [],
77
77
  starlensReplies: [],
78
78
  starlensDefaultReply: null,
79
+ controls: {
80
+ seed: {},
81
+ values: {},
82
+ unavailable: /* @__PURE__ */ new Set(),
83
+ lastUsage: null,
84
+ writes: [],
85
+ refusal: null
86
+ },
79
87
  allowNetwork: false
80
88
  }));
81
89
 
package/dist/setup.js CHANGED
@@ -108,6 +108,14 @@ var state = globalSingleton("state", () => ({
108
108
  starlensCalls: [],
109
109
  starlensReplies: [],
110
110
  starlensDefaultReply: null,
111
+ controls: {
112
+ seed: {},
113
+ values: {},
114
+ unavailable: /* @__PURE__ */ new Set(),
115
+ lastUsage: null,
116
+ writes: [],
117
+ refusal: null
118
+ },
111
119
  allowNetwork: false
112
120
  }));
113
121
  function requireDb() {
@@ -3010,6 +3018,91 @@ async function handleEdgeRequest(request, subPath) {
3010
3018
  return edgeFailure(`No edge simulator for ${method} .../edge${subPath}`, 404);
3011
3019
  }
3012
3020
 
3021
+ // src/simulator/controls.ts
3022
+ var USER_TOKEN_HEADER = "x-stardeck-user-token";
3023
+ var REFUSAL_STATUS = {
3024
+ NOT_WRITABLE: 400,
3025
+ INVALID_VALUE: 400,
3026
+ UNAUTHENTICATED: 401,
3027
+ FORBIDDEN: 403,
3028
+ WRITE_NOT_ALLOWED_HERE: 403,
3029
+ PROVIDER_NOT_READY: 409,
3030
+ UNAVAILABLE: 503
3031
+ };
3032
+ var controlsModule = null;
3033
+ function loadControls() {
3034
+ controlsModule ??= import("@stardeck-customer-apps/controls");
3035
+ return controlsModule;
3036
+ }
3037
+ function currentControlsBody() {
3038
+ const controls = {};
3039
+ for (const [key, value] of Object.entries(state.controls.values)) {
3040
+ controls[key] = state.controls.unavailable.has(key) ? { ok: false, reason: "unavailable (simulated)" } : { ok: true, value };
3041
+ }
3042
+ return controls;
3043
+ }
3044
+ function refusalResponse(refusal) {
3045
+ return json({ error: refusal }, REFUSAL_STATUS[refusal.code]);
3046
+ }
3047
+ function handleSync(body) {
3048
+ const usage = body.usage;
3049
+ if (typeof usage === "object" && usage !== null) {
3050
+ const locations = usage.locations;
3051
+ state.controls.lastUsage = typeof locations === "number" && Number.isInteger(locations) && locations >= 0 ? { locations } : {};
3052
+ }
3053
+ return json({ controls: currentControlsBody() });
3054
+ }
3055
+ async function handleWrite(request, key) {
3056
+ const { CONTROLS, isWritableControl } = await loadControls();
3057
+ if (!isWritableControl(key)) {
3058
+ return refusalResponse({
3059
+ code: "NOT_WRITABLE",
3060
+ message: `${key} cannot be changed from the app`
3061
+ });
3062
+ }
3063
+ const userToken = request.headers.get(USER_TOKEN_HEADER);
3064
+ if (!userToken) {
3065
+ return refusalResponse({
3066
+ code: "UNAUTHENTICATED",
3067
+ message: "A signed-in person is required (X-Stardeck-User-Token)"
3068
+ });
3069
+ }
3070
+ const body = await readJsonBody(request);
3071
+ const parsed = CONTROLS[key].schema.safeParse(body.value);
3072
+ if (!parsed.success) {
3073
+ return refusalResponse({
3074
+ code: "INVALID_VALUE",
3075
+ message: parsed.error.issues[0]?.message ?? "Invalid value"
3076
+ });
3077
+ }
3078
+ const refusal = state.controls.refusal;
3079
+ if (refusal) {
3080
+ state.controls.refusal = null;
3081
+ return refusalResponse(refusal);
3082
+ }
3083
+ state.controls.values[key] = parsed.data;
3084
+ state.controls.unavailable.delete(key);
3085
+ state.controls.writes.push({ key, value: parsed.data, userToken });
3086
+ return json({ controls: currentControlsBody() });
3087
+ }
3088
+ var CONTROLS_PATH = /^\/api\/deployments\/[^/]+\/controls(?:\/([^/]+))?$/;
3089
+ async function handleControlsRequest(request, url) {
3090
+ const match = url.pathname.match(CONTROLS_PATH);
3091
+ const key = match?.[1] ? decodeURIComponent(match[1]) : null;
3092
+ if (!key && request.method === "POST") {
3093
+ return handleSync(await readJsonBody(request));
3094
+ }
3095
+ if (key && request.method === "PUT") {
3096
+ return handleWrite(request, key);
3097
+ }
3098
+ return json(
3099
+ {
3100
+ error: { code: "UNAVAILABLE", message: `No simulator for ${request.method} ${url.pathname}` }
3101
+ },
3102
+ 404
3103
+ );
3104
+ }
3105
+
3013
3106
  // src/simulator/schedules.ts
3014
3107
  async function handleScheduleWake(request, scheduleKey) {
3015
3108
  if (request.method !== "POST") return failure(`Method ${request.method} not allowed`, 405);
@@ -3158,8 +3251,10 @@ function requiresDeploymentHmac(request, url) {
3158
3251
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
3159
3252
  const isStarlens = url.pathname === "/api/starlens/extract";
3160
3253
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
3161
- return !!(isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
3254
+ const controlsMatch = url.pathname.match(CONTROLS_PATH2);
3255
+ return !!(controlsMatch || isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
3162
3256
  }
3257
+ var CONTROLS_PATH2 = /^\/api\/deployments\/[^/]+\/controls(\/[^/]+)?$/;
3163
3258
  var SCHEDULE_WAKE_PATH = /^\/api\/deployments\/[^/]+\/schedules\/([^/]+)\/wake$/;
3164
3259
  function deploymentIdFromPath(url) {
3165
3260
  const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
@@ -3227,6 +3322,9 @@ async function handleSimulatedRequest(request, url) {
3227
3322
  if (edgeMatch) {
3228
3323
  return handleEdgeRequest(request, edgeMatch[1] ?? "");
3229
3324
  }
3325
+ if (CONTROLS_PATH2.test(url.pathname)) {
3326
+ return handleControlsRequest(request, url);
3327
+ }
3230
3328
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
3231
3329
  if (scheduleWakeMatch) {
3232
3330
  return handleScheduleWake(request, decodeURIComponent(scheduleWakeMatch[1]));
@@ -3255,7 +3353,7 @@ async function handleSimulatedRequest(request, url) {
3255
3353
  }
3256
3354
  }
3257
3355
  return failure(
3258
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, edge print/display/bindings, starlens extract, schedule wakes, auth verify/refresh, Neon /sql.`,
3356
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, edge print/display/bindings, app controls sync/write, starlens extract, schedule wakes, auth verify/refresh, Neon /sql.`,
3259
3357
  404
3260
3358
  );
3261
3359
  }
package/dist/setup.mjs CHANGED
@@ -84,6 +84,14 @@ var state = globalSingleton("state", () => ({
84
84
  starlensCalls: [],
85
85
  starlensReplies: [],
86
86
  starlensDefaultReply: null,
87
+ controls: {
88
+ seed: {},
89
+ values: {},
90
+ unavailable: /* @__PURE__ */ new Set(),
91
+ lastUsage: null,
92
+ writes: [],
93
+ refusal: null
94
+ },
87
95
  allowNetwork: false
88
96
  }));
89
97
  function requireDb() {
@@ -2986,6 +2994,91 @@ async function handleEdgeRequest(request, subPath) {
2986
2994
  return edgeFailure(`No edge simulator for ${method} .../edge${subPath}`, 404);
2987
2995
  }
2988
2996
 
2997
+ // src/simulator/controls.ts
2998
+ var USER_TOKEN_HEADER = "x-stardeck-user-token";
2999
+ var REFUSAL_STATUS = {
3000
+ NOT_WRITABLE: 400,
3001
+ INVALID_VALUE: 400,
3002
+ UNAUTHENTICATED: 401,
3003
+ FORBIDDEN: 403,
3004
+ WRITE_NOT_ALLOWED_HERE: 403,
3005
+ PROVIDER_NOT_READY: 409,
3006
+ UNAVAILABLE: 503
3007
+ };
3008
+ var controlsModule = null;
3009
+ function loadControls() {
3010
+ controlsModule ??= import("@stardeck-customer-apps/controls");
3011
+ return controlsModule;
3012
+ }
3013
+ function currentControlsBody() {
3014
+ const controls = {};
3015
+ for (const [key, value] of Object.entries(state.controls.values)) {
3016
+ controls[key] = state.controls.unavailable.has(key) ? { ok: false, reason: "unavailable (simulated)" } : { ok: true, value };
3017
+ }
3018
+ return controls;
3019
+ }
3020
+ function refusalResponse(refusal) {
3021
+ return json({ error: refusal }, REFUSAL_STATUS[refusal.code]);
3022
+ }
3023
+ function handleSync(body) {
3024
+ const usage = body.usage;
3025
+ if (typeof usage === "object" && usage !== null) {
3026
+ const locations = usage.locations;
3027
+ state.controls.lastUsage = typeof locations === "number" && Number.isInteger(locations) && locations >= 0 ? { locations } : {};
3028
+ }
3029
+ return json({ controls: currentControlsBody() });
3030
+ }
3031
+ async function handleWrite(request, key) {
3032
+ const { CONTROLS, isWritableControl } = await loadControls();
3033
+ if (!isWritableControl(key)) {
3034
+ return refusalResponse({
3035
+ code: "NOT_WRITABLE",
3036
+ message: `${key} cannot be changed from the app`
3037
+ });
3038
+ }
3039
+ const userToken = request.headers.get(USER_TOKEN_HEADER);
3040
+ if (!userToken) {
3041
+ return refusalResponse({
3042
+ code: "UNAUTHENTICATED",
3043
+ message: "A signed-in person is required (X-Stardeck-User-Token)"
3044
+ });
3045
+ }
3046
+ const body = await readJsonBody(request);
3047
+ const parsed = CONTROLS[key].schema.safeParse(body.value);
3048
+ if (!parsed.success) {
3049
+ return refusalResponse({
3050
+ code: "INVALID_VALUE",
3051
+ message: parsed.error.issues[0]?.message ?? "Invalid value"
3052
+ });
3053
+ }
3054
+ const refusal = state.controls.refusal;
3055
+ if (refusal) {
3056
+ state.controls.refusal = null;
3057
+ return refusalResponse(refusal);
3058
+ }
3059
+ state.controls.values[key] = parsed.data;
3060
+ state.controls.unavailable.delete(key);
3061
+ state.controls.writes.push({ key, value: parsed.data, userToken });
3062
+ return json({ controls: currentControlsBody() });
3063
+ }
3064
+ var CONTROLS_PATH = /^\/api\/deployments\/[^/]+\/controls(?:\/([^/]+))?$/;
3065
+ async function handleControlsRequest(request, url) {
3066
+ const match = url.pathname.match(CONTROLS_PATH);
3067
+ const key = match?.[1] ? decodeURIComponent(match[1]) : null;
3068
+ if (!key && request.method === "POST") {
3069
+ return handleSync(await readJsonBody(request));
3070
+ }
3071
+ if (key && request.method === "PUT") {
3072
+ return handleWrite(request, key);
3073
+ }
3074
+ return json(
3075
+ {
3076
+ error: { code: "UNAVAILABLE", message: `No simulator for ${request.method} ${url.pathname}` }
3077
+ },
3078
+ 404
3079
+ );
3080
+ }
3081
+
2989
3082
  // src/simulator/schedules.ts
2990
3083
  async function handleScheduleWake(request, scheduleKey) {
2991
3084
  if (request.method !== "POST") return failure(`Method ${request.method} not allowed`, 405);
@@ -3134,8 +3227,10 @@ function requiresDeploymentHmac(request, url) {
3134
3227
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
3135
3228
  const isStarlens = url.pathname === "/api/starlens/extract";
3136
3229
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
3137
- return !!(isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
3230
+ const controlsMatch = url.pathname.match(CONTROLS_PATH2);
3231
+ return !!(controlsMatch || isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
3138
3232
  }
3233
+ var CONTROLS_PATH2 = /^\/api\/deployments\/[^/]+\/controls(\/[^/]+)?$/;
3139
3234
  var SCHEDULE_WAKE_PATH = /^\/api\/deployments\/[^/]+\/schedules\/([^/]+)\/wake$/;
3140
3235
  function deploymentIdFromPath(url) {
3141
3236
  const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
@@ -3203,6 +3298,9 @@ async function handleSimulatedRequest(request, url) {
3203
3298
  if (edgeMatch) {
3204
3299
  return handleEdgeRequest(request, edgeMatch[1] ?? "");
3205
3300
  }
3301
+ if (CONTROLS_PATH2.test(url.pathname)) {
3302
+ return handleControlsRequest(request, url);
3303
+ }
3206
3304
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
3207
3305
  if (scheduleWakeMatch) {
3208
3306
  return handleScheduleWake(request, decodeURIComponent(scheduleWakeMatch[1]));
@@ -3231,7 +3329,7 @@ async function handleSimulatedRequest(request, url) {
3231
3329
  }
3232
3330
  }
3233
3331
  return failure(
3234
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, edge print/display/bindings, starlens extract, schedule wakes, auth verify/refresh, Neon /sql.`,
3332
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, edge print/display/bindings, app controls sync/write, starlens extract, schedule wakes, auth verify/refresh, Neon /sql.`,
3235
3333
  404
3236
3334
  );
3237
3335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/testing",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Vitest test harness for Stardeck customer apps — in-process Postgres (PGlite) plus a control-plane simulator so the real Stardeck SDKs run unmodified in tests",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -65,6 +65,7 @@
65
65
  "@electric-sql/pglite": "^0.3.0"
66
66
  },
67
67
  "peerDependencies": {
68
+ "@stardeck-customer-apps/controls": ">=0.1.0",
68
69
  "@stardeck-customer-apps/edge-sdk": ">=1.4.0",
69
70
  "@stardeck-customer-apps/integrations-sdk": ">=1.16.0",
70
71
  "@stardeck-customer-apps/payments-sdk": ">=0.1.0",
@@ -74,6 +75,9 @@
74
75
  "@stardeck-customer-apps/starlens": ">=0.1.0"
75
76
  },
76
77
  "peerDependenciesMeta": {
78
+ "@stardeck-customer-apps/controls": {
79
+ "optional": true
80
+ },
77
81
  "@stardeck-customer-apps/integrations-sdk": {
78
82
  "optional": true
79
83
  },
@@ -95,6 +99,7 @@
95
99
  },
96
100
  "devDependencies": {
97
101
  "@neondatabase/serverless": "^1.0.0",
102
+ "@stardeck-customer-apps/controls": "*",
98
103
  "@stardeck-customer-apps/data-store-sdk": "*",
99
104
  "@stardeck-customer-apps/edge-sdk": "*",
100
105
  "@stardeck-customer-apps/email-sdk": "*",