@stardeck-customer-apps/testing 0.1.1 → 0.3.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
@@ -85,6 +85,9 @@ describeWorkflow("checkout", () => {
85
85
  auth flows (`__stardeck:sess`).
86
86
  - `app.inbox` — emails sent through the email-sdk: `.latest(addr?)`,
87
87
  `.to(addr)`, `.all()`, `.count`, `.clear()`.
88
+ - `app.identities` — the platform-identity directory created through
89
+ integrations-sdk `client.identities`: `.get(id)`, `.links(id)`, `.all()`,
90
+ `.count`, `.clear()`.
88
91
  - `app.query(sql, params?)` / `app.db` — direct database access for
89
92
  assertions and seeding.
90
93
  - `callRoute(handler, opts)` — invoke an App Router route handler with a real
@@ -104,6 +107,10 @@ describeWorkflow("checkout", () => {
104
107
  - `getSession()` / `requireAuth()` from project-auth — resolves the
105
108
  `asUser(...)` user (header fast path) or issued session cookies.
106
109
  - `EmailClient.send()` — captured in `app.inbox`, never delivered.
110
+ - `client.identities` from integrations-sdk (create/get/update/list accounts &
111
+ persons, attach channel links) — served offline by the simulated directory;
112
+ `update` replaces the `profile` object (not a merge), like the control plane.
113
+ Inspect via `app.identities`. Merge/archive are dashboard-only — not simulated.
107
114
  - `next/headers` (`headers()`/`cookies()`) inside handlers under `callRoute`.
108
115
 
109
116
  ## Rules
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { PGlite } from '@electric-sql/pglite';
2
+ import { ModuleSchemaOp, ModuleDataPort, IdentityClient } from '@stardeck-customer-apps/core';
2
3
 
3
4
  /**
4
5
  * The user shape returned by project-auth's `getSession()`. Mirrors the
@@ -43,6 +44,47 @@ interface TestInbox {
43
44
  clear(): void;
44
45
  get count(): number;
45
46
  }
47
+ /**
48
+ * A platform identity captured by the simulated directory. Mirrors the `Identity`
49
+ * shape returned by @stardeck-customer-apps/integrations-sdk's `client.identities`
50
+ * — keep in sync.
51
+ */
52
+ interface CapturedIdentity {
53
+ id: string;
54
+ type: "person" | "account";
55
+ parentId: string | null;
56
+ displayName: string | null;
57
+ profile: Record<string, unknown>;
58
+ status: "active" | "merged" | "archived";
59
+ mergedIntoId: string | null;
60
+ externalRef: string | null;
61
+ createdAt: string;
62
+ updatedAt: string;
63
+ }
64
+ /** A channel identifier / login attached to a person identity. */
65
+ interface CapturedIdentityLink {
66
+ id: string;
67
+ identityId: string;
68
+ kind: string;
69
+ externalId: string;
70
+ verified: boolean;
71
+ createdAt: string;
72
+ }
73
+ /**
74
+ * Inspect the simulated platform-identity directory — the in-memory stand-in for
75
+ * the control plane that `client.identities` talks to in tests. Identities and
76
+ * links created by app code under test land here; assert on them like `inbox`.
77
+ */
78
+ interface TestDirectory {
79
+ /** All identities, oldest first. */
80
+ all(): CapturedIdentity[];
81
+ /** A single identity by id, or undefined. */
82
+ get(id: string): CapturedIdentity | undefined;
83
+ /** Channel/login links attached to an identity. */
84
+ links(identityId: string): CapturedIdentityLink[];
85
+ clear(): void;
86
+ get count(): number;
87
+ }
46
88
  interface TestAppOptions {
47
89
  /**
48
90
  * Path to the schema.sql snapshot generated by
@@ -70,6 +112,8 @@ interface TestApp {
70
112
  db: PGlite;
71
113
  /** Captured outbound email. */
72
114
  inbox: TestInbox;
115
+ /** Inspect the simulated platform-identity directory (`client.identities`). */
116
+ identities: TestDirectory;
73
117
  /** Convenience for raw SQL: `app.query("SELECT ...", [param])`. */
74
118
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
75
119
  /**
@@ -97,6 +141,52 @@ interface TestApp {
97
141
  */
98
142
  declare function createTestApp(options?: TestAppOptions): Promise<TestApp>;
99
143
 
144
+ /** The minimal slice of a module definition the harness needs to stand it up. */
145
+ interface MountableModule {
146
+ schema: ModuleSchemaOp[];
147
+ }
148
+ interface ModuleSeedDeps {
149
+ /** Raw-SQL data port over the harness PGlite — what the module engine reads/writes through. */
150
+ data: ModuleDataPort;
151
+ /** Identity client backed by the simulated platform identity directory. */
152
+ identities: IdentityClient;
153
+ }
154
+ interface CreateModuleAppOptions {
155
+ /** Modules whose install schema is rendered to DDL and applied to the test DB. */
156
+ modules: MountableModule[];
157
+ /**
158
+ * Seed run once after the schema is applied, and again on every `reset()`.
159
+ * Receives the same data port + identity client the module engine uses, so a
160
+ * module's own `seed*` function drops straight in.
161
+ */
162
+ seed?: (deps: ModuleSeedDeps) => Promise<void>;
163
+ /** Allow real outbound network (default false). */
164
+ allowNetwork?: boolean;
165
+ }
166
+ interface ModuleApp extends ModuleSeedDeps {
167
+ /** The underlying harness app (db, identities inspector, callRoute, asUser, …). */
168
+ app: TestApp;
169
+ /** Reset to a clean slate: drop + re-apply the module schema, clear the sim, re-run the seed. */
170
+ reset(): Promise<void>;
171
+ /** Tear down (restore global fetch, close the db). */
172
+ close(): Promise<void>;
173
+ }
174
+ /**
175
+ * Stand a capability module up on the in-process platform simulator — the **L2**
176
+ * harness. It renders the module's install schema to real DDL (the SAME renderer
177
+ * the control plane uses, drift-tested in apps/web), applies it to PGlite, wires a
178
+ * raw-SQL data port over it, and a real `integrations-sdk` identity client talking
179
+ * to the simulated identity directory (so scoping + governance behave as in prod).
180
+ *
181
+ * That's a near-end-to-end test of a module's runtime — schema + data port +
182
+ * identity integration + engine — minus the HTTP/RSC shell (those belong to a
183
+ * real-deploy smoke). The same seams back the local dev playground.
184
+ *
185
+ * Adding a module costs one co-located test file calling this with its own
186
+ * definition + seed; there is no per-module harness to maintain.
187
+ */
188
+ declare function createModuleApp(options: CreateModuleAppOptions): Promise<ModuleApp>;
189
+
100
190
  type RouteHandler<Req extends Request, P> = (request: Req, context: {
101
191
  params: Promise<P>;
102
192
  }) => Promise<Response> | Response;
@@ -140,4 +230,4 @@ declare const TEST_ENV_DEFAULTS: {
140
230
  /** Default location of the DDL snapshot written by `generate-types`. */
141
231
  declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
142
232
 
143
- export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
233
+ export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CreateModuleAppOptions, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type ModuleApp, type ModuleSeedDeps, type MountableModule, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createModuleApp, createTestApp, describeWorkflow, parseWorkflowName };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { PGlite } from '@electric-sql/pglite';
2
+ import { ModuleSchemaOp, ModuleDataPort, IdentityClient } from '@stardeck-customer-apps/core';
2
3
 
3
4
  /**
4
5
  * The user shape returned by project-auth's `getSession()`. Mirrors the
@@ -43,6 +44,47 @@ interface TestInbox {
43
44
  clear(): void;
44
45
  get count(): number;
45
46
  }
47
+ /**
48
+ * A platform identity captured by the simulated directory. Mirrors the `Identity`
49
+ * shape returned by @stardeck-customer-apps/integrations-sdk's `client.identities`
50
+ * — keep in sync.
51
+ */
52
+ interface CapturedIdentity {
53
+ id: string;
54
+ type: "person" | "account";
55
+ parentId: string | null;
56
+ displayName: string | null;
57
+ profile: Record<string, unknown>;
58
+ status: "active" | "merged" | "archived";
59
+ mergedIntoId: string | null;
60
+ externalRef: string | null;
61
+ createdAt: string;
62
+ updatedAt: string;
63
+ }
64
+ /** A channel identifier / login attached to a person identity. */
65
+ interface CapturedIdentityLink {
66
+ id: string;
67
+ identityId: string;
68
+ kind: string;
69
+ externalId: string;
70
+ verified: boolean;
71
+ createdAt: string;
72
+ }
73
+ /**
74
+ * Inspect the simulated platform-identity directory — the in-memory stand-in for
75
+ * the control plane that `client.identities` talks to in tests. Identities and
76
+ * links created by app code under test land here; assert on them like `inbox`.
77
+ */
78
+ interface TestDirectory {
79
+ /** All identities, oldest first. */
80
+ all(): CapturedIdentity[];
81
+ /** A single identity by id, or undefined. */
82
+ get(id: string): CapturedIdentity | undefined;
83
+ /** Channel/login links attached to an identity. */
84
+ links(identityId: string): CapturedIdentityLink[];
85
+ clear(): void;
86
+ get count(): number;
87
+ }
46
88
  interface TestAppOptions {
47
89
  /**
48
90
  * Path to the schema.sql snapshot generated by
@@ -70,6 +112,8 @@ interface TestApp {
70
112
  db: PGlite;
71
113
  /** Captured outbound email. */
72
114
  inbox: TestInbox;
115
+ /** Inspect the simulated platform-identity directory (`client.identities`). */
116
+ identities: TestDirectory;
73
117
  /** Convenience for raw SQL: `app.query("SELECT ...", [param])`. */
74
118
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
75
119
  /**
@@ -97,6 +141,52 @@ interface TestApp {
97
141
  */
98
142
  declare function createTestApp(options?: TestAppOptions): Promise<TestApp>;
99
143
 
144
+ /** The minimal slice of a module definition the harness needs to stand it up. */
145
+ interface MountableModule {
146
+ schema: ModuleSchemaOp[];
147
+ }
148
+ interface ModuleSeedDeps {
149
+ /** Raw-SQL data port over the harness PGlite — what the module engine reads/writes through. */
150
+ data: ModuleDataPort;
151
+ /** Identity client backed by the simulated platform identity directory. */
152
+ identities: IdentityClient;
153
+ }
154
+ interface CreateModuleAppOptions {
155
+ /** Modules whose install schema is rendered to DDL and applied to the test DB. */
156
+ modules: MountableModule[];
157
+ /**
158
+ * Seed run once after the schema is applied, and again on every `reset()`.
159
+ * Receives the same data port + identity client the module engine uses, so a
160
+ * module's own `seed*` function drops straight in.
161
+ */
162
+ seed?: (deps: ModuleSeedDeps) => Promise<void>;
163
+ /** Allow real outbound network (default false). */
164
+ allowNetwork?: boolean;
165
+ }
166
+ interface ModuleApp extends ModuleSeedDeps {
167
+ /** The underlying harness app (db, identities inspector, callRoute, asUser, …). */
168
+ app: TestApp;
169
+ /** Reset to a clean slate: drop + re-apply the module schema, clear the sim, re-run the seed. */
170
+ reset(): Promise<void>;
171
+ /** Tear down (restore global fetch, close the db). */
172
+ close(): Promise<void>;
173
+ }
174
+ /**
175
+ * Stand a capability module up on the in-process platform simulator — the **L2**
176
+ * harness. It renders the module's install schema to real DDL (the SAME renderer
177
+ * the control plane uses, drift-tested in apps/web), applies it to PGlite, wires a
178
+ * raw-SQL data port over it, and a real `integrations-sdk` identity client talking
179
+ * to the simulated identity directory (so scoping + governance behave as in prod).
180
+ *
181
+ * That's a near-end-to-end test of a module's runtime — schema + data port +
182
+ * identity integration + engine — minus the HTTP/RSC shell (those belong to a
183
+ * real-deploy smoke). The same seams back the local dev playground.
184
+ *
185
+ * Adding a module costs one co-located test file calling this with its own
186
+ * definition + seed; there is no per-module harness to maintain.
187
+ */
188
+ declare function createModuleApp(options: CreateModuleAppOptions): Promise<ModuleApp>;
189
+
100
190
  type RouteHandler<Req extends Request, P> = (request: Req, context: {
101
191
  params: Promise<P>;
102
192
  }) => Promise<Response> | Response;
@@ -140,4 +230,4 @@ declare const TEST_ENV_DEFAULTS: {
140
230
  /** Default location of the DDL snapshot written by `generate-types`. */
141
231
  declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
142
232
 
143
- export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
233
+ export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CreateModuleAppOptions, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type ModuleApp, type ModuleSeedDeps, type MountableModule, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createModuleApp, createTestApp, describeWorkflow, parseWorkflowName };
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ __export(index_exports, {
36
36
  TEST_ENV_DEFAULTS: () => TEST_ENV_DEFAULTS,
37
37
  WORKFLOW_NAME_PREFIX: () => WORKFLOW_NAME_PREFIX,
38
38
  callRoute: () => callRoute,
39
+ createModuleApp: () => createModuleApp,
39
40
  createTestApp: () => createTestApp,
40
41
  describeWorkflow: () => describeWorkflow,
41
42
  parseWorkflowName: () => parseWorkflowName
@@ -43,7 +44,7 @@ __export(index_exports, {
43
44
  module.exports = __toCommonJS(index_exports);
44
45
 
45
46
  // src/test-app.ts
46
- var import_node_crypto3 = __toESM(require("crypto"));
47
+ var import_node_crypto4 = __toESM(require("crypto"));
47
48
  var import_node_fs = require("fs");
48
49
  var import_node_path = require("path");
49
50
 
@@ -96,6 +97,8 @@ var state = globalSingleton("state", () => ({
96
97
  refreshSessions: /* @__PURE__ */ new Map(),
97
98
  emails: [],
98
99
  emailCounter: 0,
100
+ identities: /* @__PURE__ */ new Map(),
101
+ identityLinks: [],
99
102
  allowNetwork: false
100
103
  }));
101
104
  function requireDb() {
@@ -155,8 +158,8 @@ function verifyDeploymentAuthHeader(secret, header) {
155
158
  return null;
156
159
  }
157
160
  if (payload.type !== "deployment-request") return null;
158
- const now = Math.floor(Date.now() / 1e3);
159
- if (Math.abs(now - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
161
+ const now2 = Math.floor(Date.now() / 1e3);
162
+ if (Math.abs(now2 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
160
163
  return payload;
161
164
  }
162
165
 
@@ -460,6 +463,7 @@ var FIELD_TYPE_TO_PG = {
460
463
  currency: "numeric",
461
464
  rating: "integer",
462
465
  relation: "uuid",
466
+ identity: "uuid",
463
467
  file_ref: "jsonb",
464
468
  file_refs: "jsonb",
465
469
  json: "jsonb"
@@ -637,6 +641,153 @@ function createInbox() {
637
641
  };
638
642
  }
639
643
 
644
+ // src/simulator/identities.ts
645
+ var import_node_crypto3 = __toESM(require("crypto"));
646
+ var LINK_KINDS = /* @__PURE__ */ new Set([
647
+ "line",
648
+ "facebook",
649
+ "instagram",
650
+ "email",
651
+ "phone",
652
+ "project_auth_user",
653
+ "dashboard_user"
654
+ ]);
655
+ function now() {
656
+ return (/* @__PURE__ */ new Date()).toISOString();
657
+ }
658
+ function linksFor(identityId) {
659
+ return state.identityLinks.filter((l) => l.identityId === identityId);
660
+ }
661
+ async function readBody(request) {
662
+ try {
663
+ return await request.json();
664
+ } catch {
665
+ return {};
666
+ }
667
+ }
668
+ function handleList(request) {
669
+ const typeParam = new URL(request.url).searchParams.get("type");
670
+ const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
671
+ const identities = [...state.identities.values()];
672
+ return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
673
+ }
674
+ async function handleCreate(request) {
675
+ const body = await readBody(request);
676
+ const type = body.type;
677
+ if (type !== "person" && type !== "account") {
678
+ return failure("type must be 'person' or 'account'");
679
+ }
680
+ let parentId = null;
681
+ if (type === "person" && body.parentId) {
682
+ const parent = state.identities.get(body.parentId);
683
+ if (!parent) return failure("parent identity not found");
684
+ if (parent.type !== "account") {
685
+ return failure("a person's parent must be an account");
686
+ }
687
+ parentId = parent.id;
688
+ }
689
+ const identity = {
690
+ id: import_node_crypto3.default.randomUUID(),
691
+ type,
692
+ parentId,
693
+ displayName: body.displayName ?? null,
694
+ profile: body.profile ?? {},
695
+ status: "active",
696
+ mergedIntoId: null,
697
+ externalRef: body.externalRef ?? null,
698
+ createdAt: now(),
699
+ updatedAt: now()
700
+ };
701
+ state.identities.set(identity.id, identity);
702
+ return success({ identity });
703
+ }
704
+ function handleGet(identityId) {
705
+ const identity = state.identities.get(identityId);
706
+ if (!identity) return failure("identity not found", 404);
707
+ return success({ identity, links: linksFor(identityId) });
708
+ }
709
+ async function handleUpdate(identityId, request) {
710
+ const identity = state.identities.get(identityId);
711
+ if (!identity) return failure("identity not found", 404);
712
+ const body = await readBody(request);
713
+ if (body.displayName !== void 0) {
714
+ identity.displayName = body.displayName;
715
+ }
716
+ if (body.profile !== void 0) {
717
+ identity.profile = body.profile;
718
+ }
719
+ if (body.externalRef !== void 0) {
720
+ identity.externalRef = body.externalRef;
721
+ }
722
+ identity.updatedAt = now();
723
+ return success({ identity });
724
+ }
725
+ async function handleAttachLink(identityId, request) {
726
+ const identity = state.identities.get(identityId);
727
+ if (!identity) return failure("identity not found", 404);
728
+ if (identity.type !== "person") {
729
+ return failure("links attach only to a person identity");
730
+ }
731
+ if (identity.status !== "active") {
732
+ return failure("links attach only to active persons");
733
+ }
734
+ const body = await readBody(request);
735
+ const kind = body.kind;
736
+ const externalId = body.externalId;
737
+ if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
738
+ return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
739
+ }
740
+ if (typeof externalId !== "string" || !externalId) {
741
+ return failure("externalId is required");
742
+ }
743
+ const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
744
+ if (existing) {
745
+ if (existing.identityId === identityId) return success({ link: existing });
746
+ return failure("identifier already linked to another identity", 409);
747
+ }
748
+ const link = {
749
+ id: import_node_crypto3.default.randomUUID(),
750
+ identityId,
751
+ kind,
752
+ externalId,
753
+ verified: body.verified === true,
754
+ createdAt: now()
755
+ };
756
+ state.identityLinks.push(link);
757
+ return success({ link });
758
+ }
759
+ async function handleIdentitiesRequest(request, subPath) {
760
+ const method = request.method;
761
+ if (subPath === "" || subPath === "/") {
762
+ if (method === "GET") return handleList(request);
763
+ if (method === "POST") return handleCreate(request);
764
+ }
765
+ const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
766
+ if (linksMatch && method === "POST") {
767
+ return handleAttachLink(linksMatch[1], request);
768
+ }
769
+ const singleMatch = subPath.match(/^\/([^/]+)$/);
770
+ if (singleMatch) {
771
+ if (method === "GET") return handleGet(singleMatch[1]);
772
+ if (method === "PATCH") return handleUpdate(singleMatch[1], request);
773
+ }
774
+ return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
775
+ }
776
+ function createDirectory() {
777
+ return {
778
+ all: () => [...state.identities.values()],
779
+ get: (id) => state.identities.get(id),
780
+ links: (identityId) => linksFor(identityId),
781
+ clear: () => {
782
+ state.identities.clear();
783
+ state.identityLinks = [];
784
+ },
785
+ get count() {
786
+ return state.identities.size;
787
+ }
788
+ };
789
+ }
790
+
640
791
  // src/simulator/router.ts
641
792
  var fetchHolder = globalSingleton("fetch-holder", () => ({
642
793
  originalFetch: null
@@ -654,7 +805,8 @@ async function handleSimulatedRequest(request, url) {
654
805
  }
655
806
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
656
807
  const isEmail = url.pathname === "/api/email/send";
657
- if (dataStoreMatch || isEmail) {
808
+ const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
809
+ if (dataStoreMatch || isEmail || identitiesMatch) {
658
810
  const authHeader = request.headers.get("X-Stardeck-Auth");
659
811
  if (!authHeader) {
660
812
  return failure("Missing authentication header", 401);
@@ -667,28 +819,31 @@ async function handleSimulatedRequest(request, url) {
667
819
  if (isEmail && request.method === "POST") {
668
820
  return handleEmailSend(request);
669
821
  }
822
+ if (identitiesMatch) {
823
+ return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
824
+ }
670
825
  if (dataStoreMatch) {
671
826
  const subPath = dataStoreMatch[1] ?? "";
672
827
  const db = requireDb();
673
- const readBody = async () => await request.json();
828
+ const readBody2 = async () => await request.json();
674
829
  if (subPath === "/query" && request.method === "POST") {
675
- return handleQuery(db, await readBody());
830
+ return handleQuery(db, await readBody2());
676
831
  }
677
832
  if (subPath === "/mutate" && request.method === "POST") {
678
- return handleMutate(db, await readBody());
833
+ return handleMutate(db, await readBody2());
679
834
  }
680
835
  if (subPath === "/schema" && request.method === "GET") {
681
836
  return handleGetSchema(db);
682
837
  }
683
838
  if (subPath === "/schema/tables" && request.method === "POST") {
684
- return handleCreateTable(db, await readBody());
839
+ return handleCreateTable(db, await readBody2());
685
840
  }
686
841
  if (subPath === "/schema/columns" && request.method === "POST") {
687
- return handleAddColumn(db, await readBody());
842
+ return handleAddColumn(db, await readBody2());
688
843
  }
689
844
  }
690
845
  return failure(
691
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, auth verify/refresh, Neon /sql.`,
846
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
692
847
  404
693
848
  );
694
849
  }
@@ -773,9 +928,11 @@ async function createTestApp(options = {}) {
773
928
  throw error;
774
929
  }
775
930
  const inbox = createInbox();
931
+ const directory = createDirectory();
776
932
  const app = {
777
933
  db,
778
934
  inbox,
935
+ identities: directory,
779
936
  async query(sql, params = []) {
780
937
  const result = await db.query(sql, params);
781
938
  return result.rows;
@@ -791,8 +948,8 @@ async function createTestApp(options = {}) {
791
948
  issueSession(user) {
792
949
  const fullUser = buildUser(user);
793
950
  const tokens = {
794
- accessToken: `test-access-${import_node_crypto3.default.randomUUID()}`,
795
- refreshToken: `test-refresh-${import_node_crypto3.default.randomUUID()}`
951
+ accessToken: `test-access-${import_node_crypto4.default.randomUUID()}`,
952
+ refreshToken: `test-refresh-${import_node_crypto4.default.randomUUID()}`
796
953
  };
797
954
  state.sessions.set(tokens.accessToken, fullUser);
798
955
  state.refreshSessions.set(tokens.refreshToken, fullUser);
@@ -806,6 +963,8 @@ async function createTestApp(options = {}) {
806
963
  state.refreshSessions.clear();
807
964
  state.emails = [];
808
965
  state.emailCounter = 0;
966
+ state.identities.clear();
967
+ state.identityLinks = [];
809
968
  },
810
969
  async close() {
811
970
  state.db = null;
@@ -813,6 +972,8 @@ async function createTestApp(options = {}) {
813
972
  state.sessions.clear();
814
973
  state.refreshSessions.clear();
815
974
  state.emails = [];
975
+ state.identities.clear();
976
+ state.identityLinks = [];
816
977
  uninstallFetchRouter();
817
978
  await db.close();
818
979
  }
@@ -820,6 +981,39 @@ async function createTestApp(options = {}) {
820
981
  return app;
821
982
  }
822
983
 
984
+ // src/module-app.ts
985
+ var import_core = require("@stardeck-customer-apps/core");
986
+ async function createModuleApp(options) {
987
+ const schemaSql = options.modules.map((m) => (0, import_core.renderSchemaOpsToSql)(m.schema)).join("\n\n");
988
+ const app = await createTestApp({ schemaSql, allowNetwork: options.allowNetwork });
989
+ const sql = { query: (text, params) => app.db.query(text, params ?? []) };
990
+ const data = (0, import_core.makeSqlPort)(sql);
991
+ const { createIntegrationsClient } = await import("@stardeck-customer-apps/integrations-sdk");
992
+ const identities = createIntegrationsClient({
993
+ controlPlaneUrl: TEST_ENV_DEFAULTS.CONTROL_PLANE_URL,
994
+ organizationId: TEST_ENV_DEFAULTS.ORGANIZATION_ID,
995
+ projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
996
+ deploymentId: TEST_ENV_DEFAULTS.DEPLOYMENT_ID,
997
+ deploymentSecret: TEST_ENV_DEFAULTS.DEPLOYMENT_SECRET
998
+ }).identities;
999
+ const runSeed = async () => {
1000
+ if (options.seed) await options.seed({ data, identities });
1001
+ };
1002
+ await runSeed();
1003
+ return {
1004
+ app,
1005
+ data,
1006
+ identities,
1007
+ async reset() {
1008
+ await app.reset();
1009
+ await runSeed();
1010
+ },
1011
+ async close() {
1012
+ await app.close();
1013
+ }
1014
+ };
1015
+ }
1016
+
823
1017
  // src/next/headers-shim.ts
824
1018
  var import_node_async_hooks = require("async_hooks");
825
1019
  var requestScopeStorage = globalSingleton(
@@ -917,6 +1111,7 @@ function parseWorkflowName(describeTitle) {
917
1111
  TEST_ENV_DEFAULTS,
918
1112
  WORKFLOW_NAME_PREFIX,
919
1113
  callRoute,
1114
+ createModuleApp,
920
1115
  createTestApp,
921
1116
  describeWorkflow,
922
1117
  parseWorkflowName