@stardeck-customer-apps/testing 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -43,6 +43,47 @@ interface TestInbox {
43
43
  clear(): void;
44
44
  get count(): number;
45
45
  }
46
+ /**
47
+ * A platform identity captured by the simulated directory. Mirrors the `Identity`
48
+ * shape returned by @stardeck-customer-apps/integrations-sdk's `client.identities`
49
+ * — keep in sync.
50
+ */
51
+ interface CapturedIdentity {
52
+ id: string;
53
+ type: "person" | "account";
54
+ parentId: string | null;
55
+ displayName: string | null;
56
+ profile: Record<string, unknown>;
57
+ status: "active" | "merged" | "archived";
58
+ mergedIntoId: string | null;
59
+ externalRef: string | null;
60
+ createdAt: string;
61
+ updatedAt: string;
62
+ }
63
+ /** A channel identifier / login attached to a person identity. */
64
+ interface CapturedIdentityLink {
65
+ id: string;
66
+ identityId: string;
67
+ kind: string;
68
+ externalId: string;
69
+ verified: boolean;
70
+ createdAt: string;
71
+ }
72
+ /**
73
+ * Inspect the simulated platform-identity directory — the in-memory stand-in for
74
+ * the control plane that `client.identities` talks to in tests. Identities and
75
+ * links created by app code under test land here; assert on them like `inbox`.
76
+ */
77
+ interface TestDirectory {
78
+ /** All identities, oldest first. */
79
+ all(): CapturedIdentity[];
80
+ /** A single identity by id, or undefined. */
81
+ get(id: string): CapturedIdentity | undefined;
82
+ /** Channel/login links attached to an identity. */
83
+ links(identityId: string): CapturedIdentityLink[];
84
+ clear(): void;
85
+ get count(): number;
86
+ }
46
87
  interface TestAppOptions {
47
88
  /**
48
89
  * Path to the schema.sql snapshot generated by
@@ -70,6 +111,8 @@ interface TestApp {
70
111
  db: PGlite;
71
112
  /** Captured outbound email. */
72
113
  inbox: TestInbox;
114
+ /** Inspect the simulated platform-identity directory (`client.identities`). */
115
+ identities: TestDirectory;
73
116
  /** Convenience for raw SQL: `app.query("SELECT ...", [param])`. */
74
117
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
75
118
  /**
@@ -140,4 +183,4 @@ declare const TEST_ENV_DEFAULTS: {
140
183
  /** Default location of the DDL snapshot written by `generate-types`. */
141
184
  declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
142
185
 
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 };
186
+ export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
package/dist/index.d.ts CHANGED
@@ -43,6 +43,47 @@ interface TestInbox {
43
43
  clear(): void;
44
44
  get count(): number;
45
45
  }
46
+ /**
47
+ * A platform identity captured by the simulated directory. Mirrors the `Identity`
48
+ * shape returned by @stardeck-customer-apps/integrations-sdk's `client.identities`
49
+ * — keep in sync.
50
+ */
51
+ interface CapturedIdentity {
52
+ id: string;
53
+ type: "person" | "account";
54
+ parentId: string | null;
55
+ displayName: string | null;
56
+ profile: Record<string, unknown>;
57
+ status: "active" | "merged" | "archived";
58
+ mergedIntoId: string | null;
59
+ externalRef: string | null;
60
+ createdAt: string;
61
+ updatedAt: string;
62
+ }
63
+ /** A channel identifier / login attached to a person identity. */
64
+ interface CapturedIdentityLink {
65
+ id: string;
66
+ identityId: string;
67
+ kind: string;
68
+ externalId: string;
69
+ verified: boolean;
70
+ createdAt: string;
71
+ }
72
+ /**
73
+ * Inspect the simulated platform-identity directory — the in-memory stand-in for
74
+ * the control plane that `client.identities` talks to in tests. Identities and
75
+ * links created by app code under test land here; assert on them like `inbox`.
76
+ */
77
+ interface TestDirectory {
78
+ /** All identities, oldest first. */
79
+ all(): CapturedIdentity[];
80
+ /** A single identity by id, or undefined. */
81
+ get(id: string): CapturedIdentity | undefined;
82
+ /** Channel/login links attached to an identity. */
83
+ links(identityId: string): CapturedIdentityLink[];
84
+ clear(): void;
85
+ get count(): number;
86
+ }
46
87
  interface TestAppOptions {
47
88
  /**
48
89
  * Path to the schema.sql snapshot generated by
@@ -70,6 +111,8 @@ interface TestApp {
70
111
  db: PGlite;
71
112
  /** Captured outbound email. */
72
113
  inbox: TestInbox;
114
+ /** Inspect the simulated platform-identity directory (`client.identities`). */
115
+ identities: TestDirectory;
73
116
  /** Convenience for raw SQL: `app.query("SELECT ...", [param])`. */
74
117
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
75
118
  /**
@@ -140,4 +183,4 @@ declare const TEST_ENV_DEFAULTS: {
140
183
  /** Default location of the DDL snapshot written by `generate-types`. */
141
184
  declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
142
185
 
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 };
186
+ export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
package/dist/index.js CHANGED
@@ -43,7 +43,7 @@ __export(index_exports, {
43
43
  module.exports = __toCommonJS(index_exports);
44
44
 
45
45
  // src/test-app.ts
46
- var import_node_crypto3 = __toESM(require("crypto"));
46
+ var import_node_crypto4 = __toESM(require("crypto"));
47
47
  var import_node_fs = require("fs");
48
48
  var import_node_path = require("path");
49
49
 
@@ -96,6 +96,8 @@ var state = globalSingleton("state", () => ({
96
96
  refreshSessions: /* @__PURE__ */ new Map(),
97
97
  emails: [],
98
98
  emailCounter: 0,
99
+ identities: /* @__PURE__ */ new Map(),
100
+ identityLinks: [],
99
101
  allowNetwork: false
100
102
  }));
101
103
  function requireDb() {
@@ -155,8 +157,8 @@ function verifyDeploymentAuthHeader(secret, header) {
155
157
  return null;
156
158
  }
157
159
  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;
160
+ const now2 = Math.floor(Date.now() / 1e3);
161
+ if (Math.abs(now2 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
160
162
  return payload;
161
163
  }
162
164
 
@@ -460,6 +462,7 @@ var FIELD_TYPE_TO_PG = {
460
462
  currency: "numeric",
461
463
  rating: "integer",
462
464
  relation: "uuid",
465
+ identity: "uuid",
463
466
  file_ref: "jsonb",
464
467
  file_refs: "jsonb",
465
468
  json: "jsonb"
@@ -507,6 +510,18 @@ async function handleAddColumn(db, body) {
507
510
  }
508
511
 
509
512
  // src/simulator/neon-proxy.ts
513
+ var BOOL_OID = 16;
514
+ var PG_BOOL_TRUE = /* @__PURE__ */ new Set(["true", "t", "1", "yes", "y", "on"]);
515
+ var PG_BOOL_FALSE = /* @__PURE__ */ new Set(["false", "f", "0", "no", "n", "off"]);
516
+ var neonParamSerializers = {
517
+ [BOOL_OID]: (value) => {
518
+ if (typeof value === "boolean") return value ? "t" : "f";
519
+ const normalized = String(value).trim().toLowerCase();
520
+ if (PG_BOOL_TRUE.has(normalized)) return "t";
521
+ if (PG_BOOL_FALSE.has(normalized)) return "f";
522
+ throw new Error(`Invalid input for boolean type: ${JSON.stringify(value)}`);
523
+ }
524
+ };
510
525
  async function handleNeonSql(db, request) {
511
526
  const body = await request.json();
512
527
  if (body.queries) {
@@ -524,6 +539,7 @@ async function handleNeonSql(db, request) {
524
539
  try {
525
540
  const result = await db.query(body.query, body.params ?? [], {
526
541
  parsers: getIdentityParsers(),
542
+ serializers: neonParamSerializers,
527
543
  rowMode: arrayMode ? "array" : "object"
528
544
  });
529
545
  const command = body.query.trim().split(/\s+/)[0]?.toUpperCase() ?? "SELECT";
@@ -624,6 +640,153 @@ function createInbox() {
624
640
  };
625
641
  }
626
642
 
643
+ // src/simulator/identities.ts
644
+ var import_node_crypto3 = __toESM(require("crypto"));
645
+ var LINK_KINDS = /* @__PURE__ */ new Set([
646
+ "line",
647
+ "facebook",
648
+ "instagram",
649
+ "email",
650
+ "phone",
651
+ "project_auth_user",
652
+ "dashboard_user"
653
+ ]);
654
+ function now() {
655
+ return (/* @__PURE__ */ new Date()).toISOString();
656
+ }
657
+ function linksFor(identityId) {
658
+ return state.identityLinks.filter((l) => l.identityId === identityId);
659
+ }
660
+ async function readBody(request) {
661
+ try {
662
+ return await request.json();
663
+ } catch {
664
+ return {};
665
+ }
666
+ }
667
+ function handleList(request) {
668
+ const typeParam = new URL(request.url).searchParams.get("type");
669
+ const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
670
+ const identities = [...state.identities.values()];
671
+ return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
672
+ }
673
+ async function handleCreate(request) {
674
+ const body = await readBody(request);
675
+ const type = body.type;
676
+ if (type !== "person" && type !== "account") {
677
+ return failure("type must be 'person' or 'account'");
678
+ }
679
+ let parentId = null;
680
+ if (type === "person" && body.parentId) {
681
+ const parent = state.identities.get(body.parentId);
682
+ if (!parent) return failure("parent identity not found");
683
+ if (parent.type !== "account") {
684
+ return failure("a person's parent must be an account");
685
+ }
686
+ parentId = parent.id;
687
+ }
688
+ const identity = {
689
+ id: import_node_crypto3.default.randomUUID(),
690
+ type,
691
+ parentId,
692
+ displayName: body.displayName ?? null,
693
+ profile: body.profile ?? {},
694
+ status: "active",
695
+ mergedIntoId: null,
696
+ externalRef: body.externalRef ?? null,
697
+ createdAt: now(),
698
+ updatedAt: now()
699
+ };
700
+ state.identities.set(identity.id, identity);
701
+ return success({ identity });
702
+ }
703
+ function handleGet(identityId) {
704
+ const identity = state.identities.get(identityId);
705
+ if (!identity) return failure("identity not found", 404);
706
+ return success({ identity, links: linksFor(identityId) });
707
+ }
708
+ async function handleUpdate(identityId, request) {
709
+ const identity = state.identities.get(identityId);
710
+ if (!identity) return failure("identity not found", 404);
711
+ const body = await readBody(request);
712
+ if (body.displayName !== void 0) {
713
+ identity.displayName = body.displayName;
714
+ }
715
+ if (body.profile !== void 0) {
716
+ identity.profile = body.profile;
717
+ }
718
+ if (body.externalRef !== void 0) {
719
+ identity.externalRef = body.externalRef;
720
+ }
721
+ identity.updatedAt = now();
722
+ return success({ identity });
723
+ }
724
+ async function handleAttachLink(identityId, request) {
725
+ const identity = state.identities.get(identityId);
726
+ if (!identity) return failure("identity not found", 404);
727
+ if (identity.type !== "person") {
728
+ return failure("links attach only to a person identity");
729
+ }
730
+ if (identity.status !== "active") {
731
+ return failure("links attach only to active persons");
732
+ }
733
+ const body = await readBody(request);
734
+ const kind = body.kind;
735
+ const externalId = body.externalId;
736
+ if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
737
+ return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
738
+ }
739
+ if (typeof externalId !== "string" || !externalId) {
740
+ return failure("externalId is required");
741
+ }
742
+ const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
743
+ if (existing) {
744
+ if (existing.identityId === identityId) return success({ link: existing });
745
+ return failure("identifier already linked to another identity", 409);
746
+ }
747
+ const link = {
748
+ id: import_node_crypto3.default.randomUUID(),
749
+ identityId,
750
+ kind,
751
+ externalId,
752
+ verified: body.verified === true,
753
+ createdAt: now()
754
+ };
755
+ state.identityLinks.push(link);
756
+ return success({ link });
757
+ }
758
+ async function handleIdentitiesRequest(request, subPath) {
759
+ const method = request.method;
760
+ if (subPath === "" || subPath === "/") {
761
+ if (method === "GET") return handleList(request);
762
+ if (method === "POST") return handleCreate(request);
763
+ }
764
+ const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
765
+ if (linksMatch && method === "POST") {
766
+ return handleAttachLink(linksMatch[1], request);
767
+ }
768
+ const singleMatch = subPath.match(/^\/([^/]+)$/);
769
+ if (singleMatch) {
770
+ if (method === "GET") return handleGet(singleMatch[1]);
771
+ if (method === "PATCH") return handleUpdate(singleMatch[1], request);
772
+ }
773
+ return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
774
+ }
775
+ function createDirectory() {
776
+ return {
777
+ all: () => [...state.identities.values()],
778
+ get: (id) => state.identities.get(id),
779
+ links: (identityId) => linksFor(identityId),
780
+ clear: () => {
781
+ state.identities.clear();
782
+ state.identityLinks = [];
783
+ },
784
+ get count() {
785
+ return state.identities.size;
786
+ }
787
+ };
788
+ }
789
+
627
790
  // src/simulator/router.ts
628
791
  var fetchHolder = globalSingleton("fetch-holder", () => ({
629
792
  originalFetch: null
@@ -641,7 +804,8 @@ async function handleSimulatedRequest(request, url) {
641
804
  }
642
805
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
643
806
  const isEmail = url.pathname === "/api/email/send";
644
- if (dataStoreMatch || isEmail) {
807
+ const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
808
+ if (dataStoreMatch || isEmail || identitiesMatch) {
645
809
  const authHeader = request.headers.get("X-Stardeck-Auth");
646
810
  if (!authHeader) {
647
811
  return failure("Missing authentication header", 401);
@@ -654,28 +818,31 @@ async function handleSimulatedRequest(request, url) {
654
818
  if (isEmail && request.method === "POST") {
655
819
  return handleEmailSend(request);
656
820
  }
821
+ if (identitiesMatch) {
822
+ return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
823
+ }
657
824
  if (dataStoreMatch) {
658
825
  const subPath = dataStoreMatch[1] ?? "";
659
826
  const db = requireDb();
660
- const readBody = async () => await request.json();
827
+ const readBody2 = async () => await request.json();
661
828
  if (subPath === "/query" && request.method === "POST") {
662
- return handleQuery(db, await readBody());
829
+ return handleQuery(db, await readBody2());
663
830
  }
664
831
  if (subPath === "/mutate" && request.method === "POST") {
665
- return handleMutate(db, await readBody());
832
+ return handleMutate(db, await readBody2());
666
833
  }
667
834
  if (subPath === "/schema" && request.method === "GET") {
668
835
  return handleGetSchema(db);
669
836
  }
670
837
  if (subPath === "/schema/tables" && request.method === "POST") {
671
- return handleCreateTable(db, await readBody());
838
+ return handleCreateTable(db, await readBody2());
672
839
  }
673
840
  if (subPath === "/schema/columns" && request.method === "POST") {
674
- return handleAddColumn(db, await readBody());
841
+ return handleAddColumn(db, await readBody2());
675
842
  }
676
843
  }
677
844
  return failure(
678
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, auth verify/refresh, Neon /sql.`,
845
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
679
846
  404
680
847
  );
681
848
  }
@@ -760,9 +927,11 @@ async function createTestApp(options = {}) {
760
927
  throw error;
761
928
  }
762
929
  const inbox = createInbox();
930
+ const directory = createDirectory();
763
931
  const app = {
764
932
  db,
765
933
  inbox,
934
+ identities: directory,
766
935
  async query(sql, params = []) {
767
936
  const result = await db.query(sql, params);
768
937
  return result.rows;
@@ -778,8 +947,8 @@ async function createTestApp(options = {}) {
778
947
  issueSession(user) {
779
948
  const fullUser = buildUser(user);
780
949
  const tokens = {
781
- accessToken: `test-access-${import_node_crypto3.default.randomUUID()}`,
782
- refreshToken: `test-refresh-${import_node_crypto3.default.randomUUID()}`
950
+ accessToken: `test-access-${import_node_crypto4.default.randomUUID()}`,
951
+ refreshToken: `test-refresh-${import_node_crypto4.default.randomUUID()}`
783
952
  };
784
953
  state.sessions.set(tokens.accessToken, fullUser);
785
954
  state.refreshSessions.set(tokens.refreshToken, fullUser);
@@ -793,6 +962,8 @@ async function createTestApp(options = {}) {
793
962
  state.refreshSessions.clear();
794
963
  state.emails = [];
795
964
  state.emailCounter = 0;
965
+ state.identities.clear();
966
+ state.identityLinks = [];
796
967
  },
797
968
  async close() {
798
969
  state.db = null;
@@ -800,6 +971,8 @@ async function createTestApp(options = {}) {
800
971
  state.sessions.clear();
801
972
  state.refreshSessions.clear();
802
973
  state.emails = [];
974
+ state.identities.clear();
975
+ state.identityLinks = [];
803
976
  uninstallFetchRouter();
804
977
  await db.close();
805
978
  }
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/test-app.ts
2
- import crypto3 from "crypto";
2
+ import crypto4 from "crypto";
3
3
  import { readFileSync, existsSync } from "fs";
4
4
  import { resolve } from "path";
5
5
 
@@ -52,6 +52,8 @@ var state = globalSingleton("state", () => ({
52
52
  refreshSessions: /* @__PURE__ */ new Map(),
53
53
  emails: [],
54
54
  emailCounter: 0,
55
+ identities: /* @__PURE__ */ new Map(),
56
+ identityLinks: [],
55
57
  allowNetwork: false
56
58
  }));
57
59
  function requireDb() {
@@ -111,8 +113,8 @@ function verifyDeploymentAuthHeader(secret, header) {
111
113
  return null;
112
114
  }
113
115
  if (payload.type !== "deployment-request") return null;
114
- const now = Math.floor(Date.now() / 1e3);
115
- if (Math.abs(now - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
116
+ const now2 = Math.floor(Date.now() / 1e3);
117
+ if (Math.abs(now2 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
116
118
  return payload;
117
119
  }
118
120
 
@@ -416,6 +418,7 @@ var FIELD_TYPE_TO_PG = {
416
418
  currency: "numeric",
417
419
  rating: "integer",
418
420
  relation: "uuid",
421
+ identity: "uuid",
419
422
  file_ref: "jsonb",
420
423
  file_refs: "jsonb",
421
424
  json: "jsonb"
@@ -463,6 +466,18 @@ async function handleAddColumn(db, body) {
463
466
  }
464
467
 
465
468
  // src/simulator/neon-proxy.ts
469
+ var BOOL_OID = 16;
470
+ var PG_BOOL_TRUE = /* @__PURE__ */ new Set(["true", "t", "1", "yes", "y", "on"]);
471
+ var PG_BOOL_FALSE = /* @__PURE__ */ new Set(["false", "f", "0", "no", "n", "off"]);
472
+ var neonParamSerializers = {
473
+ [BOOL_OID]: (value) => {
474
+ if (typeof value === "boolean") return value ? "t" : "f";
475
+ const normalized = String(value).trim().toLowerCase();
476
+ if (PG_BOOL_TRUE.has(normalized)) return "t";
477
+ if (PG_BOOL_FALSE.has(normalized)) return "f";
478
+ throw new Error(`Invalid input for boolean type: ${JSON.stringify(value)}`);
479
+ }
480
+ };
466
481
  async function handleNeonSql(db, request) {
467
482
  const body = await request.json();
468
483
  if (body.queries) {
@@ -480,6 +495,7 @@ async function handleNeonSql(db, request) {
480
495
  try {
481
496
  const result = await db.query(body.query, body.params ?? [], {
482
497
  parsers: getIdentityParsers(),
498
+ serializers: neonParamSerializers,
483
499
  rowMode: arrayMode ? "array" : "object"
484
500
  });
485
501
  const command = body.query.trim().split(/\s+/)[0]?.toUpperCase() ?? "SELECT";
@@ -580,6 +596,153 @@ function createInbox() {
580
596
  };
581
597
  }
582
598
 
599
+ // src/simulator/identities.ts
600
+ import crypto3 from "crypto";
601
+ var LINK_KINDS = /* @__PURE__ */ new Set([
602
+ "line",
603
+ "facebook",
604
+ "instagram",
605
+ "email",
606
+ "phone",
607
+ "project_auth_user",
608
+ "dashboard_user"
609
+ ]);
610
+ function now() {
611
+ return (/* @__PURE__ */ new Date()).toISOString();
612
+ }
613
+ function linksFor(identityId) {
614
+ return state.identityLinks.filter((l) => l.identityId === identityId);
615
+ }
616
+ async function readBody(request) {
617
+ try {
618
+ return await request.json();
619
+ } catch {
620
+ return {};
621
+ }
622
+ }
623
+ function handleList(request) {
624
+ const typeParam = new URL(request.url).searchParams.get("type");
625
+ const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
626
+ const identities = [...state.identities.values()];
627
+ return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
628
+ }
629
+ async function handleCreate(request) {
630
+ const body = await readBody(request);
631
+ const type = body.type;
632
+ if (type !== "person" && type !== "account") {
633
+ return failure("type must be 'person' or 'account'");
634
+ }
635
+ let parentId = null;
636
+ if (type === "person" && body.parentId) {
637
+ const parent = state.identities.get(body.parentId);
638
+ if (!parent) return failure("parent identity not found");
639
+ if (parent.type !== "account") {
640
+ return failure("a person's parent must be an account");
641
+ }
642
+ parentId = parent.id;
643
+ }
644
+ const identity = {
645
+ id: crypto3.randomUUID(),
646
+ type,
647
+ parentId,
648
+ displayName: body.displayName ?? null,
649
+ profile: body.profile ?? {},
650
+ status: "active",
651
+ mergedIntoId: null,
652
+ externalRef: body.externalRef ?? null,
653
+ createdAt: now(),
654
+ updatedAt: now()
655
+ };
656
+ state.identities.set(identity.id, identity);
657
+ return success({ identity });
658
+ }
659
+ function handleGet(identityId) {
660
+ const identity = state.identities.get(identityId);
661
+ if (!identity) return failure("identity not found", 404);
662
+ return success({ identity, links: linksFor(identityId) });
663
+ }
664
+ async function handleUpdate(identityId, request) {
665
+ const identity = state.identities.get(identityId);
666
+ if (!identity) return failure("identity not found", 404);
667
+ const body = await readBody(request);
668
+ if (body.displayName !== void 0) {
669
+ identity.displayName = body.displayName;
670
+ }
671
+ if (body.profile !== void 0) {
672
+ identity.profile = body.profile;
673
+ }
674
+ if (body.externalRef !== void 0) {
675
+ identity.externalRef = body.externalRef;
676
+ }
677
+ identity.updatedAt = now();
678
+ return success({ identity });
679
+ }
680
+ async function handleAttachLink(identityId, request) {
681
+ const identity = state.identities.get(identityId);
682
+ if (!identity) return failure("identity not found", 404);
683
+ if (identity.type !== "person") {
684
+ return failure("links attach only to a person identity");
685
+ }
686
+ if (identity.status !== "active") {
687
+ return failure("links attach only to active persons");
688
+ }
689
+ const body = await readBody(request);
690
+ const kind = body.kind;
691
+ const externalId = body.externalId;
692
+ if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
693
+ return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
694
+ }
695
+ if (typeof externalId !== "string" || !externalId) {
696
+ return failure("externalId is required");
697
+ }
698
+ const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
699
+ if (existing) {
700
+ if (existing.identityId === identityId) return success({ link: existing });
701
+ return failure("identifier already linked to another identity", 409);
702
+ }
703
+ const link = {
704
+ id: crypto3.randomUUID(),
705
+ identityId,
706
+ kind,
707
+ externalId,
708
+ verified: body.verified === true,
709
+ createdAt: now()
710
+ };
711
+ state.identityLinks.push(link);
712
+ return success({ link });
713
+ }
714
+ async function handleIdentitiesRequest(request, subPath) {
715
+ const method = request.method;
716
+ if (subPath === "" || subPath === "/") {
717
+ if (method === "GET") return handleList(request);
718
+ if (method === "POST") return handleCreate(request);
719
+ }
720
+ const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
721
+ if (linksMatch && method === "POST") {
722
+ return handleAttachLink(linksMatch[1], request);
723
+ }
724
+ const singleMatch = subPath.match(/^\/([^/]+)$/);
725
+ if (singleMatch) {
726
+ if (method === "GET") return handleGet(singleMatch[1]);
727
+ if (method === "PATCH") return handleUpdate(singleMatch[1], request);
728
+ }
729
+ return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
730
+ }
731
+ function createDirectory() {
732
+ return {
733
+ all: () => [...state.identities.values()],
734
+ get: (id) => state.identities.get(id),
735
+ links: (identityId) => linksFor(identityId),
736
+ clear: () => {
737
+ state.identities.clear();
738
+ state.identityLinks = [];
739
+ },
740
+ get count() {
741
+ return state.identities.size;
742
+ }
743
+ };
744
+ }
745
+
583
746
  // src/simulator/router.ts
584
747
  var fetchHolder = globalSingleton("fetch-holder", () => ({
585
748
  originalFetch: null
@@ -597,7 +760,8 @@ async function handleSimulatedRequest(request, url) {
597
760
  }
598
761
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
599
762
  const isEmail = url.pathname === "/api/email/send";
600
- if (dataStoreMatch || isEmail) {
763
+ const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
764
+ if (dataStoreMatch || isEmail || identitiesMatch) {
601
765
  const authHeader = request.headers.get("X-Stardeck-Auth");
602
766
  if (!authHeader) {
603
767
  return failure("Missing authentication header", 401);
@@ -610,28 +774,31 @@ async function handleSimulatedRequest(request, url) {
610
774
  if (isEmail && request.method === "POST") {
611
775
  return handleEmailSend(request);
612
776
  }
777
+ if (identitiesMatch) {
778
+ return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
779
+ }
613
780
  if (dataStoreMatch) {
614
781
  const subPath = dataStoreMatch[1] ?? "";
615
782
  const db = requireDb();
616
- const readBody = async () => await request.json();
783
+ const readBody2 = async () => await request.json();
617
784
  if (subPath === "/query" && request.method === "POST") {
618
- return handleQuery(db, await readBody());
785
+ return handleQuery(db, await readBody2());
619
786
  }
620
787
  if (subPath === "/mutate" && request.method === "POST") {
621
- return handleMutate(db, await readBody());
788
+ return handleMutate(db, await readBody2());
622
789
  }
623
790
  if (subPath === "/schema" && request.method === "GET") {
624
791
  return handleGetSchema(db);
625
792
  }
626
793
  if (subPath === "/schema/tables" && request.method === "POST") {
627
- return handleCreateTable(db, await readBody());
794
+ return handleCreateTable(db, await readBody2());
628
795
  }
629
796
  if (subPath === "/schema/columns" && request.method === "POST") {
630
- return handleAddColumn(db, await readBody());
797
+ return handleAddColumn(db, await readBody2());
631
798
  }
632
799
  }
633
800
  return failure(
634
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, auth verify/refresh, Neon /sql.`,
801
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
635
802
  404
636
803
  );
637
804
  }
@@ -716,9 +883,11 @@ async function createTestApp(options = {}) {
716
883
  throw error;
717
884
  }
718
885
  const inbox = createInbox();
886
+ const directory = createDirectory();
719
887
  const app = {
720
888
  db,
721
889
  inbox,
890
+ identities: directory,
722
891
  async query(sql, params = []) {
723
892
  const result = await db.query(sql, params);
724
893
  return result.rows;
@@ -734,8 +903,8 @@ async function createTestApp(options = {}) {
734
903
  issueSession(user) {
735
904
  const fullUser = buildUser(user);
736
905
  const tokens = {
737
- accessToken: `test-access-${crypto3.randomUUID()}`,
738
- refreshToken: `test-refresh-${crypto3.randomUUID()}`
906
+ accessToken: `test-access-${crypto4.randomUUID()}`,
907
+ refreshToken: `test-refresh-${crypto4.randomUUID()}`
739
908
  };
740
909
  state.sessions.set(tokens.accessToken, fullUser);
741
910
  state.refreshSessions.set(tokens.refreshToken, fullUser);
@@ -749,6 +918,8 @@ async function createTestApp(options = {}) {
749
918
  state.refreshSessions.clear();
750
919
  state.emails = [];
751
920
  state.emailCounter = 0;
921
+ state.identities.clear();
922
+ state.identityLinks = [];
752
923
  },
753
924
  async close() {
754
925
  state.db = null;
@@ -756,6 +927,8 @@ async function createTestApp(options = {}) {
756
927
  state.sessions.clear();
757
928
  state.refreshSessions.clear();
758
929
  state.emails = [];
930
+ state.identities.clear();
931
+ state.identityLinks = [];
759
932
  uninstallFetchRouter();
760
933
  await db.close();
761
934
  }
@@ -44,6 +44,8 @@ var state = globalSingleton("state", () => ({
44
44
  refreshSessions: /* @__PURE__ */ new Map(),
45
45
  emails: [],
46
46
  emailCounter: 0,
47
+ identities: /* @__PURE__ */ new Map(),
48
+ identityLinks: [],
47
49
  allowNetwork: false
48
50
  }));
49
51
 
@@ -17,6 +17,8 @@ var state = globalSingleton("state", () => ({
17
17
  refreshSessions: /* @__PURE__ */ new Map(),
18
18
  emails: [],
19
19
  emailCounter: 0,
20
+ identities: /* @__PURE__ */ new Map(),
21
+ identityLinks: [],
20
22
  allowNetwork: false
21
23
  }));
22
24
 
package/dist/setup.js CHANGED
@@ -38,6 +38,8 @@ var state = globalSingleton("state", () => ({
38
38
  refreshSessions: /* @__PURE__ */ new Map(),
39
39
  emails: [],
40
40
  emailCounter: 0,
41
+ identities: /* @__PURE__ */ new Map(),
42
+ identityLinks: [],
41
43
  allowNetwork: false
42
44
  }));
43
45
  function requireDb() {
@@ -89,8 +91,8 @@ function verifyDeploymentAuthHeader(secret, header) {
89
91
  return null;
90
92
  }
91
93
  if (payload.type !== "deployment-request") return null;
92
- const now = Math.floor(Date.now() / 1e3);
93
- if (Math.abs(now - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
94
+ const now2 = Math.floor(Date.now() / 1e3);
95
+ if (Math.abs(now2 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
94
96
  return payload;
95
97
  }
96
98
 
@@ -394,6 +396,7 @@ var FIELD_TYPE_TO_PG = {
394
396
  currency: "numeric",
395
397
  rating: "integer",
396
398
  relation: "uuid",
399
+ identity: "uuid",
397
400
  file_ref: "jsonb",
398
401
  file_refs: "jsonb",
399
402
  json: "jsonb"
@@ -459,6 +462,18 @@ function getIdentityParsers() {
459
462
  }
460
463
 
461
464
  // src/simulator/neon-proxy.ts
465
+ var BOOL_OID = 16;
466
+ var PG_BOOL_TRUE = /* @__PURE__ */ new Set(["true", "t", "1", "yes", "y", "on"]);
467
+ var PG_BOOL_FALSE = /* @__PURE__ */ new Set(["false", "f", "0", "no", "n", "off"]);
468
+ var neonParamSerializers = {
469
+ [BOOL_OID]: (value) => {
470
+ if (typeof value === "boolean") return value ? "t" : "f";
471
+ const normalized = String(value).trim().toLowerCase();
472
+ if (PG_BOOL_TRUE.has(normalized)) return "t";
473
+ if (PG_BOOL_FALSE.has(normalized)) return "f";
474
+ throw new Error(`Invalid input for boolean type: ${JSON.stringify(value)}`);
475
+ }
476
+ };
462
477
  async function handleNeonSql(db, request) {
463
478
  const body = await request.json();
464
479
  if (body.queries) {
@@ -476,6 +491,7 @@ async function handleNeonSql(db, request) {
476
491
  try {
477
492
  const result = await db.query(body.query, body.params ?? [], {
478
493
  parsers: getIdentityParsers(),
494
+ serializers: neonParamSerializers,
479
495
  rowMode: arrayMode ? "array" : "object"
480
496
  });
481
497
  const command = body.query.trim().split(/\s+/)[0]?.toUpperCase() ?? "SELECT";
@@ -556,6 +572,139 @@ async function handleEmailSend(request) {
556
572
  return success({ resendId, fromAddress });
557
573
  }
558
574
 
575
+ // src/simulator/identities.ts
576
+ var import_node_crypto3 = __toESM(require("crypto"));
577
+ var LINK_KINDS = /* @__PURE__ */ new Set([
578
+ "line",
579
+ "facebook",
580
+ "instagram",
581
+ "email",
582
+ "phone",
583
+ "project_auth_user",
584
+ "dashboard_user"
585
+ ]);
586
+ function now() {
587
+ return (/* @__PURE__ */ new Date()).toISOString();
588
+ }
589
+ function linksFor(identityId) {
590
+ return state.identityLinks.filter((l) => l.identityId === identityId);
591
+ }
592
+ async function readBody(request) {
593
+ try {
594
+ return await request.json();
595
+ } catch {
596
+ return {};
597
+ }
598
+ }
599
+ function handleList(request) {
600
+ const typeParam = new URL(request.url).searchParams.get("type");
601
+ const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
602
+ const identities = [...state.identities.values()];
603
+ return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
604
+ }
605
+ async function handleCreate(request) {
606
+ const body = await readBody(request);
607
+ const type = body.type;
608
+ if (type !== "person" && type !== "account") {
609
+ return failure("type must be 'person' or 'account'");
610
+ }
611
+ let parentId = null;
612
+ if (type === "person" && body.parentId) {
613
+ const parent = state.identities.get(body.parentId);
614
+ if (!parent) return failure("parent identity not found");
615
+ if (parent.type !== "account") {
616
+ return failure("a person's parent must be an account");
617
+ }
618
+ parentId = parent.id;
619
+ }
620
+ const identity = {
621
+ id: import_node_crypto3.default.randomUUID(),
622
+ type,
623
+ parentId,
624
+ displayName: body.displayName ?? null,
625
+ profile: body.profile ?? {},
626
+ status: "active",
627
+ mergedIntoId: null,
628
+ externalRef: body.externalRef ?? null,
629
+ createdAt: now(),
630
+ updatedAt: now()
631
+ };
632
+ state.identities.set(identity.id, identity);
633
+ return success({ identity });
634
+ }
635
+ function handleGet(identityId) {
636
+ const identity = state.identities.get(identityId);
637
+ if (!identity) return failure("identity not found", 404);
638
+ return success({ identity, links: linksFor(identityId) });
639
+ }
640
+ async function handleUpdate(identityId, request) {
641
+ const identity = state.identities.get(identityId);
642
+ if (!identity) return failure("identity not found", 404);
643
+ const body = await readBody(request);
644
+ if (body.displayName !== void 0) {
645
+ identity.displayName = body.displayName;
646
+ }
647
+ if (body.profile !== void 0) {
648
+ identity.profile = body.profile;
649
+ }
650
+ if (body.externalRef !== void 0) {
651
+ identity.externalRef = body.externalRef;
652
+ }
653
+ identity.updatedAt = now();
654
+ return success({ identity });
655
+ }
656
+ async function handleAttachLink(identityId, request) {
657
+ const identity = state.identities.get(identityId);
658
+ if (!identity) return failure("identity not found", 404);
659
+ if (identity.type !== "person") {
660
+ return failure("links attach only to a person identity");
661
+ }
662
+ if (identity.status !== "active") {
663
+ return failure("links attach only to active persons");
664
+ }
665
+ const body = await readBody(request);
666
+ const kind = body.kind;
667
+ const externalId = body.externalId;
668
+ if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
669
+ return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
670
+ }
671
+ if (typeof externalId !== "string" || !externalId) {
672
+ return failure("externalId is required");
673
+ }
674
+ const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
675
+ if (existing) {
676
+ if (existing.identityId === identityId) return success({ link: existing });
677
+ return failure("identifier already linked to another identity", 409);
678
+ }
679
+ const link = {
680
+ id: import_node_crypto3.default.randomUUID(),
681
+ identityId,
682
+ kind,
683
+ externalId,
684
+ verified: body.verified === true,
685
+ createdAt: now()
686
+ };
687
+ state.identityLinks.push(link);
688
+ return success({ link });
689
+ }
690
+ async function handleIdentitiesRequest(request, subPath) {
691
+ const method = request.method;
692
+ if (subPath === "" || subPath === "/") {
693
+ if (method === "GET") return handleList(request);
694
+ if (method === "POST") return handleCreate(request);
695
+ }
696
+ const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
697
+ if (linksMatch && method === "POST") {
698
+ return handleAttachLink(linksMatch[1], request);
699
+ }
700
+ const singleMatch = subPath.match(/^\/([^/]+)$/);
701
+ if (singleMatch) {
702
+ if (method === "GET") return handleGet(singleMatch[1]);
703
+ if (method === "PATCH") return handleUpdate(singleMatch[1], request);
704
+ }
705
+ return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
706
+ }
707
+
559
708
  // src/simulator/router.ts
560
709
  var fetchHolder = globalSingleton("fetch-holder", () => ({
561
710
  originalFetch: null
@@ -573,7 +722,8 @@ async function handleSimulatedRequest(request, url) {
573
722
  }
574
723
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
575
724
  const isEmail = url.pathname === "/api/email/send";
576
- if (dataStoreMatch || isEmail) {
725
+ const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
726
+ if (dataStoreMatch || isEmail || identitiesMatch) {
577
727
  const authHeader = request.headers.get("X-Stardeck-Auth");
578
728
  if (!authHeader) {
579
729
  return failure("Missing authentication header", 401);
@@ -586,28 +736,31 @@ async function handleSimulatedRequest(request, url) {
586
736
  if (isEmail && request.method === "POST") {
587
737
  return handleEmailSend(request);
588
738
  }
739
+ if (identitiesMatch) {
740
+ return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
741
+ }
589
742
  if (dataStoreMatch) {
590
743
  const subPath = dataStoreMatch[1] ?? "";
591
744
  const db = requireDb();
592
- const readBody = async () => await request.json();
745
+ const readBody2 = async () => await request.json();
593
746
  if (subPath === "/query" && request.method === "POST") {
594
- return handleQuery(db, await readBody());
747
+ return handleQuery(db, await readBody2());
595
748
  }
596
749
  if (subPath === "/mutate" && request.method === "POST") {
597
- return handleMutate(db, await readBody());
750
+ return handleMutate(db, await readBody2());
598
751
  }
599
752
  if (subPath === "/schema" && request.method === "GET") {
600
753
  return handleGetSchema(db);
601
754
  }
602
755
  if (subPath === "/schema/tables" && request.method === "POST") {
603
- return handleCreateTable(db, await readBody());
756
+ return handleCreateTable(db, await readBody2());
604
757
  }
605
758
  if (subPath === "/schema/columns" && request.method === "POST") {
606
- return handleAddColumn(db, await readBody());
759
+ return handleAddColumn(db, await readBody2());
607
760
  }
608
761
  }
609
762
  return failure(
610
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, auth verify/refresh, Neon /sql.`,
763
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
611
764
  404
612
765
  );
613
766
  }
package/dist/setup.mjs CHANGED
@@ -14,6 +14,8 @@ var state = globalSingleton("state", () => ({
14
14
  refreshSessions: /* @__PURE__ */ new Map(),
15
15
  emails: [],
16
16
  emailCounter: 0,
17
+ identities: /* @__PURE__ */ new Map(),
18
+ identityLinks: [],
17
19
  allowNetwork: false
18
20
  }));
19
21
  function requireDb() {
@@ -65,8 +67,8 @@ function verifyDeploymentAuthHeader(secret, header) {
65
67
  return null;
66
68
  }
67
69
  if (payload.type !== "deployment-request") return null;
68
- const now = Math.floor(Date.now() / 1e3);
69
- if (Math.abs(now - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
70
+ const now2 = Math.floor(Date.now() / 1e3);
71
+ if (Math.abs(now2 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
70
72
  return payload;
71
73
  }
72
74
 
@@ -370,6 +372,7 @@ var FIELD_TYPE_TO_PG = {
370
372
  currency: "numeric",
371
373
  rating: "integer",
372
374
  relation: "uuid",
375
+ identity: "uuid",
373
376
  file_ref: "jsonb",
374
377
  file_refs: "jsonb",
375
378
  json: "jsonb"
@@ -435,6 +438,18 @@ function getIdentityParsers() {
435
438
  }
436
439
 
437
440
  // src/simulator/neon-proxy.ts
441
+ var BOOL_OID = 16;
442
+ var PG_BOOL_TRUE = /* @__PURE__ */ new Set(["true", "t", "1", "yes", "y", "on"]);
443
+ var PG_BOOL_FALSE = /* @__PURE__ */ new Set(["false", "f", "0", "no", "n", "off"]);
444
+ var neonParamSerializers = {
445
+ [BOOL_OID]: (value) => {
446
+ if (typeof value === "boolean") return value ? "t" : "f";
447
+ const normalized = String(value).trim().toLowerCase();
448
+ if (PG_BOOL_TRUE.has(normalized)) return "t";
449
+ if (PG_BOOL_FALSE.has(normalized)) return "f";
450
+ throw new Error(`Invalid input for boolean type: ${JSON.stringify(value)}`);
451
+ }
452
+ };
438
453
  async function handleNeonSql(db, request) {
439
454
  const body = await request.json();
440
455
  if (body.queries) {
@@ -452,6 +467,7 @@ async function handleNeonSql(db, request) {
452
467
  try {
453
468
  const result = await db.query(body.query, body.params ?? [], {
454
469
  parsers: getIdentityParsers(),
470
+ serializers: neonParamSerializers,
455
471
  rowMode: arrayMode ? "array" : "object"
456
472
  });
457
473
  const command = body.query.trim().split(/\s+/)[0]?.toUpperCase() ?? "SELECT";
@@ -532,6 +548,139 @@ async function handleEmailSend(request) {
532
548
  return success({ resendId, fromAddress });
533
549
  }
534
550
 
551
+ // src/simulator/identities.ts
552
+ import crypto3 from "crypto";
553
+ var LINK_KINDS = /* @__PURE__ */ new Set([
554
+ "line",
555
+ "facebook",
556
+ "instagram",
557
+ "email",
558
+ "phone",
559
+ "project_auth_user",
560
+ "dashboard_user"
561
+ ]);
562
+ function now() {
563
+ return (/* @__PURE__ */ new Date()).toISOString();
564
+ }
565
+ function linksFor(identityId) {
566
+ return state.identityLinks.filter((l) => l.identityId === identityId);
567
+ }
568
+ async function readBody(request) {
569
+ try {
570
+ return await request.json();
571
+ } catch {
572
+ return {};
573
+ }
574
+ }
575
+ function handleList(request) {
576
+ const typeParam = new URL(request.url).searchParams.get("type");
577
+ const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
578
+ const identities = [...state.identities.values()];
579
+ return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
580
+ }
581
+ async function handleCreate(request) {
582
+ const body = await readBody(request);
583
+ const type = body.type;
584
+ if (type !== "person" && type !== "account") {
585
+ return failure("type must be 'person' or 'account'");
586
+ }
587
+ let parentId = null;
588
+ if (type === "person" && body.parentId) {
589
+ const parent = state.identities.get(body.parentId);
590
+ if (!parent) return failure("parent identity not found");
591
+ if (parent.type !== "account") {
592
+ return failure("a person's parent must be an account");
593
+ }
594
+ parentId = parent.id;
595
+ }
596
+ const identity = {
597
+ id: crypto3.randomUUID(),
598
+ type,
599
+ parentId,
600
+ displayName: body.displayName ?? null,
601
+ profile: body.profile ?? {},
602
+ status: "active",
603
+ mergedIntoId: null,
604
+ externalRef: body.externalRef ?? null,
605
+ createdAt: now(),
606
+ updatedAt: now()
607
+ };
608
+ state.identities.set(identity.id, identity);
609
+ return success({ identity });
610
+ }
611
+ function handleGet(identityId) {
612
+ const identity = state.identities.get(identityId);
613
+ if (!identity) return failure("identity not found", 404);
614
+ return success({ identity, links: linksFor(identityId) });
615
+ }
616
+ async function handleUpdate(identityId, request) {
617
+ const identity = state.identities.get(identityId);
618
+ if (!identity) return failure("identity not found", 404);
619
+ const body = await readBody(request);
620
+ if (body.displayName !== void 0) {
621
+ identity.displayName = body.displayName;
622
+ }
623
+ if (body.profile !== void 0) {
624
+ identity.profile = body.profile;
625
+ }
626
+ if (body.externalRef !== void 0) {
627
+ identity.externalRef = body.externalRef;
628
+ }
629
+ identity.updatedAt = now();
630
+ return success({ identity });
631
+ }
632
+ async function handleAttachLink(identityId, request) {
633
+ const identity = state.identities.get(identityId);
634
+ if (!identity) return failure("identity not found", 404);
635
+ if (identity.type !== "person") {
636
+ return failure("links attach only to a person identity");
637
+ }
638
+ if (identity.status !== "active") {
639
+ return failure("links attach only to active persons");
640
+ }
641
+ const body = await readBody(request);
642
+ const kind = body.kind;
643
+ const externalId = body.externalId;
644
+ if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
645
+ return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
646
+ }
647
+ if (typeof externalId !== "string" || !externalId) {
648
+ return failure("externalId is required");
649
+ }
650
+ const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
651
+ if (existing) {
652
+ if (existing.identityId === identityId) return success({ link: existing });
653
+ return failure("identifier already linked to another identity", 409);
654
+ }
655
+ const link = {
656
+ id: crypto3.randomUUID(),
657
+ identityId,
658
+ kind,
659
+ externalId,
660
+ verified: body.verified === true,
661
+ createdAt: now()
662
+ };
663
+ state.identityLinks.push(link);
664
+ return success({ link });
665
+ }
666
+ async function handleIdentitiesRequest(request, subPath) {
667
+ const method = request.method;
668
+ if (subPath === "" || subPath === "/") {
669
+ if (method === "GET") return handleList(request);
670
+ if (method === "POST") return handleCreate(request);
671
+ }
672
+ const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
673
+ if (linksMatch && method === "POST") {
674
+ return handleAttachLink(linksMatch[1], request);
675
+ }
676
+ const singleMatch = subPath.match(/^\/([^/]+)$/);
677
+ if (singleMatch) {
678
+ if (method === "GET") return handleGet(singleMatch[1]);
679
+ if (method === "PATCH") return handleUpdate(singleMatch[1], request);
680
+ }
681
+ return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
682
+ }
683
+
535
684
  // src/simulator/router.ts
536
685
  var fetchHolder = globalSingleton("fetch-holder", () => ({
537
686
  originalFetch: null
@@ -549,7 +698,8 @@ async function handleSimulatedRequest(request, url) {
549
698
  }
550
699
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
551
700
  const isEmail = url.pathname === "/api/email/send";
552
- if (dataStoreMatch || isEmail) {
701
+ const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
702
+ if (dataStoreMatch || isEmail || identitiesMatch) {
553
703
  const authHeader = request.headers.get("X-Stardeck-Auth");
554
704
  if (!authHeader) {
555
705
  return failure("Missing authentication header", 401);
@@ -562,28 +712,31 @@ async function handleSimulatedRequest(request, url) {
562
712
  if (isEmail && request.method === "POST") {
563
713
  return handleEmailSend(request);
564
714
  }
715
+ if (identitiesMatch) {
716
+ return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
717
+ }
565
718
  if (dataStoreMatch) {
566
719
  const subPath = dataStoreMatch[1] ?? "";
567
720
  const db = requireDb();
568
- const readBody = async () => await request.json();
721
+ const readBody2 = async () => await request.json();
569
722
  if (subPath === "/query" && request.method === "POST") {
570
- return handleQuery(db, await readBody());
723
+ return handleQuery(db, await readBody2());
571
724
  }
572
725
  if (subPath === "/mutate" && request.method === "POST") {
573
- return handleMutate(db, await readBody());
726
+ return handleMutate(db, await readBody2());
574
727
  }
575
728
  if (subPath === "/schema" && request.method === "GET") {
576
729
  return handleGetSchema(db);
577
730
  }
578
731
  if (subPath === "/schema/tables" && request.method === "POST") {
579
- return handleCreateTable(db, await readBody());
732
+ return handleCreateTable(db, await readBody2());
580
733
  }
581
734
  if (subPath === "/schema/columns" && request.method === "POST") {
582
- return handleAddColumn(db, await readBody());
735
+ return handleAddColumn(db, await readBody2());
583
736
  }
584
737
  }
585
738
  return failure(
586
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, auth verify/refresh, Neon /sql.`,
739
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
587
740
  404
588
741
  );
589
742
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/testing",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -78,6 +78,7 @@
78
78
  "@neondatabase/serverless": "^1.0.0",
79
79
  "@stardeck-customer-apps/data-store-sdk": "*",
80
80
  "@stardeck-customer-apps/email-sdk": "*",
81
+ "@stardeck-customer-apps/integrations-sdk": "*",
81
82
  "@stardeck-customer-apps/tsconfig": "*",
82
83
  "@types/node": "^24.10.1",
83
84
  "kysely": "^0.27.0",