@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 +7 -0
- package/dist/index.d.mts +44 -1
- package/dist/index.d.ts +44 -1
- package/dist/index.js +185 -12
- package/dist/index.mjs +185 -12
- package/dist/next/headers-shim.js +2 -0
- package/dist/next/headers-shim.mjs +2 -0
- package/dist/setup.js +162 -9
- package/dist/setup.mjs +162 -9
- package/package.json +2 -1
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
|
|
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
|
|
159
|
-
if (Math.abs(
|
|
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
|
-
|
|
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
|
|
827
|
+
const readBody2 = async () => await request.json();
|
|
661
828
|
if (subPath === "/query" && request.method === "POST") {
|
|
662
|
-
return handleQuery(db, await
|
|
829
|
+
return handleQuery(db, await readBody2());
|
|
663
830
|
}
|
|
664
831
|
if (subPath === "/mutate" && request.method === "POST") {
|
|
665
|
-
return handleMutate(db, await
|
|
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
|
|
838
|
+
return handleCreateTable(db, await readBody2());
|
|
672
839
|
}
|
|
673
840
|
if (subPath === "/schema/columns" && request.method === "POST") {
|
|
674
|
-
return handleAddColumn(db, await
|
|
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-${
|
|
782
|
-
refreshToken: `test-refresh-${
|
|
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
|
|
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
|
|
115
|
-
if (Math.abs(
|
|
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
|
-
|
|
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
|
|
783
|
+
const readBody2 = async () => await request.json();
|
|
617
784
|
if (subPath === "/query" && request.method === "POST") {
|
|
618
|
-
return handleQuery(db, await
|
|
785
|
+
return handleQuery(db, await readBody2());
|
|
619
786
|
}
|
|
620
787
|
if (subPath === "/mutate" && request.method === "POST") {
|
|
621
|
-
return handleMutate(db, await
|
|
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
|
|
794
|
+
return handleCreateTable(db, await readBody2());
|
|
628
795
|
}
|
|
629
796
|
if (subPath === "/schema/columns" && request.method === "POST") {
|
|
630
|
-
return handleAddColumn(db, await
|
|
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-${
|
|
738
|
-
refreshToken: `test-refresh-${
|
|
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
|
}
|
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
|
|
93
|
-
if (Math.abs(
|
|
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
|
-
|
|
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
|
|
745
|
+
const readBody2 = async () => await request.json();
|
|
593
746
|
if (subPath === "/query" && request.method === "POST") {
|
|
594
|
-
return handleQuery(db, await
|
|
747
|
+
return handleQuery(db, await readBody2());
|
|
595
748
|
}
|
|
596
749
|
if (subPath === "/mutate" && request.method === "POST") {
|
|
597
|
-
return handleMutate(db, await
|
|
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
|
|
756
|
+
return handleCreateTable(db, await readBody2());
|
|
604
757
|
}
|
|
605
758
|
if (subPath === "/schema/columns" && request.method === "POST") {
|
|
606
|
-
return handleAddColumn(db, await
|
|
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
|
|
69
|
-
if (Math.abs(
|
|
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
|
-
|
|
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
|
|
721
|
+
const readBody2 = async () => await request.json();
|
|
569
722
|
if (subPath === "/query" && request.method === "POST") {
|
|
570
|
-
return handleQuery(db, await
|
|
723
|
+
return handleQuery(db, await readBody2());
|
|
571
724
|
}
|
|
572
725
|
if (subPath === "/mutate" && request.method === "POST") {
|
|
573
|
-
return handleMutate(db, await
|
|
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
|
|
732
|
+
return handleCreateTable(db, await readBody2());
|
|
580
733
|
}
|
|
581
734
|
if (subPath === "/schema/columns" && request.method === "POST") {
|
|
582
|
-
return handleAddColumn(db, await
|
|
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.
|
|
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",
|