@plingjs/surface-gateway 0.0.0-stage → 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +177 -2
- package/bin/surface-gateway.mjs +19 -0
- package/cli.mjs +132 -0
- package/config.example.json +33 -0
- package/config.mjs +214 -0
- package/errors.mjs +15 -0
- package/gateway.mjs +935 -0
- package/index.d.ts +273 -0
- package/index.mjs +7 -0
- package/mcp.mjs +145 -0
- package/package.json +35 -4
- package/server.mjs +462 -0
- package/version.mjs +1 -0
package/config.mjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
const MAX_SCOPE_ENTRIES = 256;
|
|
4
|
+
|
|
5
|
+
export class ConfigError extends Error {
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ConfigError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function loadConfig(file) {
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = JSON.parse(await readFile(file, "utf8"));
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new ConfigError(
|
|
19
|
+
"CONFIG_READ_FAILED",
|
|
20
|
+
`Cannot read Surface Gateway config ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return normalizeConfig(raw);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeConfig(raw) {
|
|
27
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || !Array.isArray(raw.tenants)) {
|
|
28
|
+
throw new ConfigError("CONFIG_INVALID", "Config must be an object with a tenants array.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const tenants = raw.tenants.map((tenant, index) => {
|
|
33
|
+
if (!tenant || typeof tenant !== "object" || Array.isArray(tenant)) {
|
|
34
|
+
throw new ConfigError("CONFIG_INVALID", `tenants[${index}] must be an object.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const id = stringValue(tenant.id, `tenants[${index}].id`);
|
|
38
|
+
if (seen.has(id)) throw new ConfigError("CONFIG_INVALID", `Duplicate tenant id: ${id}`);
|
|
39
|
+
seen.add(id);
|
|
40
|
+
|
|
41
|
+
const rawOrigins = Array.isArray(tenant.origins)
|
|
42
|
+
? tenant.origins
|
|
43
|
+
: typeof tenant.origin === "string"
|
|
44
|
+
? [tenant.origin]
|
|
45
|
+
: [];
|
|
46
|
+
if (rawOrigins.length === 0) {
|
|
47
|
+
throw new ConfigError("CONFIG_INVALID", `tenant ${id} must configure at least one origin.`);
|
|
48
|
+
}
|
|
49
|
+
const origins = [...new Set(rawOrigins.map((origin, originIndex) => {
|
|
50
|
+
if (typeof origin !== "string" || !origin.trim()) {
|
|
51
|
+
throw new ConfigError("CONFIG_INVALID", `tenants[${index}].origins[${originIndex}] must be a non-empty string.`);
|
|
52
|
+
}
|
|
53
|
+
return normalizeOrigin(origin);
|
|
54
|
+
}))];
|
|
55
|
+
|
|
56
|
+
const maxPageSessionTtlMs = positiveInteger(
|
|
57
|
+
tenant.maxPageSessionTtlMs,
|
|
58
|
+
24 * 60 * 60_000,
|
|
59
|
+
`tenant ${id}.maxPageSessionTtlMs`,
|
|
60
|
+
);
|
|
61
|
+
const maxAgentSessionTtlMs = positiveInteger(
|
|
62
|
+
tenant.maxAgentSessionTtlMs,
|
|
63
|
+
24 * 60 * 60_000,
|
|
64
|
+
`tenant ${id}.maxAgentSessionTtlMs`,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
id,
|
|
69
|
+
displayName:
|
|
70
|
+
typeof tenant.displayName === "string" && tenant.displayName.trim()
|
|
71
|
+
? tenant.displayName.trim()
|
|
72
|
+
: id,
|
|
73
|
+
origins,
|
|
74
|
+
maxPageSessionTtlMs,
|
|
75
|
+
maxAgentSessionTtlMs,
|
|
76
|
+
publicDemo: normalizePublicDemo(
|
|
77
|
+
tenant.publicDemo,
|
|
78
|
+
id,
|
|
79
|
+
origins,
|
|
80
|
+
maxPageSessionTtlMs,
|
|
81
|
+
),
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (tenants.length === 0) {
|
|
86
|
+
throw new ConfigError("CONFIG_INVALID", "At least one tenant is required.");
|
|
87
|
+
}
|
|
88
|
+
return { tenants };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizePublicDemo(value, tenantId, tenantOrigins, maxPageSessionTtlMs) {
|
|
92
|
+
if (value === undefined || value === null) return null;
|
|
93
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
94
|
+
throw new ConfigError("CONFIG_INVALID", `tenant ${tenantId}.publicDemo must be an object.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!Array.isArray(value.origins) || value.origins.length === 0) {
|
|
98
|
+
throw new ConfigError("CONFIG_INVALID", `tenant ${tenantId}.publicDemo must configure at least one origin.`);
|
|
99
|
+
}
|
|
100
|
+
const tenantOriginSet = new Set(tenantOrigins);
|
|
101
|
+
const origins = [...new Set(value.origins.map((origin, index) => {
|
|
102
|
+
if (typeof origin !== "string" || !origin.trim()) {
|
|
103
|
+
throw new ConfigError(
|
|
104
|
+
"CONFIG_INVALID",
|
|
105
|
+
`tenant ${tenantId}.publicDemo.origins[${index}] must be a non-empty string.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return normalizeOrigin(origin);
|
|
109
|
+
}))];
|
|
110
|
+
const unknownOrigin = origins.find((origin) => !tenantOriginSet.has(origin));
|
|
111
|
+
if (unknownOrigin) {
|
|
112
|
+
throw new ConfigError(
|
|
113
|
+
"CONFIG_INVALID",
|
|
114
|
+
`tenant ${tenantId}.publicDemo origins must also appear in tenant.origins: ${unknownOrigin}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!Array.isArray(value.scope)) {
|
|
119
|
+
throw new ConfigError("CONFIG_INVALID", `tenant ${tenantId}.publicDemo.scope must be an array.`);
|
|
120
|
+
}
|
|
121
|
+
const scope = [...new Set(value.scope.map((item, index) => {
|
|
122
|
+
if (typeof item !== "string" || !item.trim()) {
|
|
123
|
+
throw new ConfigError(
|
|
124
|
+
"CONFIG_INVALID",
|
|
125
|
+
`tenant ${tenantId}.publicDemo.scope[${index}] must be a non-empty string.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return item.trim();
|
|
129
|
+
}))];
|
|
130
|
+
if (scope.length === 0) {
|
|
131
|
+
throw new ConfigError("CONFIG_INVALID", `tenant ${tenantId}.publicDemo.scope must not be empty.`);
|
|
132
|
+
}
|
|
133
|
+
if (scope.length > MAX_SCOPE_ENTRIES) {
|
|
134
|
+
throw new ConfigError(
|
|
135
|
+
"CONFIG_INVALID",
|
|
136
|
+
`tenant ${tenantId}.publicDemo.scope must contain at most ${MAX_SCOPE_ENTRIES} entries.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const pageSessionTtlMs = positiveInteger(
|
|
141
|
+
value.pageSessionTtlMs,
|
|
142
|
+
24 * 60 * 60_000,
|
|
143
|
+
`tenant ${tenantId}.publicDemo.pageSessionTtlMs`,
|
|
144
|
+
);
|
|
145
|
+
if (pageSessionTtlMs > maxPageSessionTtlMs) {
|
|
146
|
+
throw new ConfigError(
|
|
147
|
+
"CONFIG_INVALID",
|
|
148
|
+
`tenant ${tenantId}.publicDemo.pageSessionTtlMs cannot exceed tenant.maxPageSessionTtlMs.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
origins,
|
|
154
|
+
scope,
|
|
155
|
+
pageSessionTtlMs,
|
|
156
|
+
maxSessionsPerWindow: positiveInteger(
|
|
157
|
+
value.maxSessionsPerWindow,
|
|
158
|
+
30,
|
|
159
|
+
`tenant ${tenantId}.publicDemo.maxSessionsPerWindow`,
|
|
160
|
+
),
|
|
161
|
+
windowMs: positiveInteger(
|
|
162
|
+
value.windowMs,
|
|
163
|
+
10 * 60_000,
|
|
164
|
+
`tenant ${tenantId}.publicDemo.windowMs`,
|
|
165
|
+
),
|
|
166
|
+
maxActiveSessions: positiveInteger(
|
|
167
|
+
value.maxActiveSessions,
|
|
168
|
+
40,
|
|
169
|
+
`tenant ${tenantId}.publicDemo.maxActiveSessions`,
|
|
170
|
+
),
|
|
171
|
+
userIdPrefix:
|
|
172
|
+
typeof value.userIdPrefix === "string" && value.userIdPrefix.trim()
|
|
173
|
+
? value.userIdPrefix.trim()
|
|
174
|
+
: "public-demo",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function normalizeOrigin(value) {
|
|
179
|
+
let url;
|
|
180
|
+
try {
|
|
181
|
+
url = new URL(value);
|
|
182
|
+
} catch {
|
|
183
|
+
throw new ConfigError("CONFIG_INVALID", `Invalid origin: ${value}`);
|
|
184
|
+
}
|
|
185
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
186
|
+
throw new ConfigError("CONFIG_INVALID", `Origin must use http or https: ${value}`);
|
|
187
|
+
}
|
|
188
|
+
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
189
|
+
throw new ConfigError(
|
|
190
|
+
"CONFIG_INVALID",
|
|
191
|
+
`Origin must not contain a path, query, fragment, or credentials: ${value}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return url.origin;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function stringValue(value, path) {
|
|
198
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
199
|
+
throw new ConfigError("CONFIG_INVALID", `${path} must be a non-empty string.`);
|
|
200
|
+
}
|
|
201
|
+
const text = value.trim();
|
|
202
|
+
if (text.length > 128) {
|
|
203
|
+
throw new ConfigError("CONFIG_INVALID", `${path} must be at most 128 characters.`);
|
|
204
|
+
}
|
|
205
|
+
return text;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function positiveInteger(value, fallback, path) {
|
|
209
|
+
if (value === undefined || value === null) return fallback;
|
|
210
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
211
|
+
throw new ConfigError("CONFIG_INVALID", `${path} must be a positive integer.`);
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
package/errors.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class GatewayError extends Error {
|
|
2
|
+
constructor(code, message, status = 400, options) {
|
|
3
|
+
super(message, options);
|
|
4
|
+
this.name = "GatewayError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.status = status;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function asGatewayError(error) {
|
|
11
|
+
if (error instanceof GatewayError) return error;
|
|
12
|
+
return new GatewayError("INTERNAL_ERROR", "Internal gateway error.", 500, {
|
|
13
|
+
cause: error,
|
|
14
|
+
});
|
|
15
|
+
}
|