bulltrackers-module 1.0.996 → 1.0.998
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/functions/api-v3/index.js +12 -1
- package/functions/api-v3/routes/computations.js +80 -14
- package/functions/api-v3/services/computationContract.js +120 -5
- package/functions/api-v3/websocket/verification.js +320 -0
- package/functions/computation-system-v3/docs/00-INDEX.md +1 -0
- package/functions/computation-system-v3/docs/17-IDE-VERIFICATION-PLAN.md +248 -0
- package/functions/computation-system-v3/framework/core/ContextBuilder.js +12 -2
- package/functions/computation-system-v3/framework/docs/authoring/sample-computations/grade1-portfolio-summary.js +78 -63
- package/functions/computation-system-v3/framework/docs/authoring/sample-computations/grade2-entity-risk-change.js +74 -103
- package/functions/computation-system-v3/framework/docs/authoring/sample-computations/grade3-alert-on-risk-spike.js +126 -66
- package/functions/computation-system-v3/framework/metadata/ContractHash.js +31 -0
- package/functions/computation-system-v3/framework/metadata/FunctionRegistry.js +3 -0
- package/functions/computation-system-v3/verification/runVerification.js +5 -3
- package/index.js +3 -3
- package/package.json +3 -2
|
@@ -3,11 +3,22 @@
|
|
|
3
3
|
* Exports the API factory for use in index.js
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const http = require('http');
|
|
6
7
|
const { createApiV3App, initialize, getServices, resetServices } = require('./core-api');
|
|
8
|
+
const { attachVerificationWebSocketServer } = require('./websocket/verification');
|
|
9
|
+
|
|
10
|
+
function createApiV3Server(apiConfig, dependencies) {
|
|
11
|
+
const app = createApiV3App(apiConfig, dependencies);
|
|
12
|
+
const server = http.createServer(app);
|
|
13
|
+
attachVerificationWebSocketServer(server, dependencies, apiConfig);
|
|
14
|
+
return { app, server };
|
|
15
|
+
}
|
|
7
16
|
|
|
8
17
|
module.exports = {
|
|
9
18
|
createApiV3App,
|
|
19
|
+
createApiV3Server,
|
|
10
20
|
initialize,
|
|
11
21
|
getServices,
|
|
12
|
-
resetServices
|
|
22
|
+
resetServices,
|
|
23
|
+
attachVerificationWebSocketServer
|
|
13
24
|
};
|
|
@@ -11,11 +11,13 @@ const { publishUploadStreamEvent, readUploadStream } = require('../middleware/up
|
|
|
11
11
|
const { getRedis, checkVerificationUserLimit } = require('../middleware/upstashRateLimit');
|
|
12
12
|
const { v4: uuidv4 } = require('uuid');
|
|
13
13
|
const { writeComputationNotification } = require('../services/computationNotificationWriter');
|
|
14
|
-
const { buildUserComputationContract } = require('../services/computationContract');
|
|
15
|
-
const { buildSqlBlockFromRequires } = require('../../computation-system-v3/dev-tools/annotate-sql-core');
|
|
16
|
-
|
|
17
|
-
const UPLOAD_SESSION_PREFIX = 'upload:session:';
|
|
18
|
-
const UPLOAD_SESSION_TTL = 300;
|
|
14
|
+
const { buildUserComputationContract } = require('../services/computationContract');
|
|
15
|
+
const { buildSqlBlockFromRequires } = require('../../computation-system-v3/dev-tools/annotate-sql-core');
|
|
16
|
+
|
|
17
|
+
const UPLOAD_SESSION_PREFIX = 'upload:session:';
|
|
18
|
+
const UPLOAD_SESSION_TTL = 300;
|
|
19
|
+
const VERIFY_TICKET_PREFIX = 'verify:ticket:';
|
|
20
|
+
const VERIFY_TICKET_TTL_SECONDS = 30;
|
|
19
21
|
|
|
20
22
|
// Helper to create router
|
|
21
23
|
const createComputationsRouter = () => {
|
|
@@ -60,15 +62,79 @@ const createComputationsRouter = () => {
|
|
|
60
62
|
}
|
|
61
63
|
});
|
|
62
64
|
|
|
63
|
-
// GET /contract: canonical frontend contract for user computations
|
|
64
|
-
router.
|
|
65
|
-
try {
|
|
66
|
-
const contract = buildUserComputationContract();
|
|
67
|
-
res.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
65
|
+
// GET /contract: canonical frontend contract for user computations
|
|
66
|
+
router.head('/contract', requireVerifiedUser, async (req, res, next) => {
|
|
67
|
+
try {
|
|
68
|
+
const contract = buildUserComputationContract();
|
|
69
|
+
res.setHeader('x-bt-sdk-version', contract.sdkVersion || '');
|
|
70
|
+
res.setHeader('x-bt-contract-hash', contract.contractHash || '');
|
|
71
|
+
res.setHeader('x-bt-generated-at', contract.generatedAt || '');
|
|
72
|
+
res.status(200).end();
|
|
73
|
+
} catch (error) {
|
|
74
|
+
next(error);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
router.get('/contract', requireVerifiedUser, async (req, res, next) => {
|
|
79
|
+
try {
|
|
80
|
+
const contract = buildUserComputationContract();
|
|
81
|
+
res.json({ success: true, data: contract });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
next(error);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// POST /verify/start: issue a single-use verification ticket
|
|
88
|
+
router.post('/verify/start', requireVerifiedUser, async (req, res, next) => {
|
|
89
|
+
try {
|
|
90
|
+
const userId = req.targetUserId;
|
|
91
|
+
const { code, name } = req.body || {};
|
|
92
|
+
if (!code || typeof code !== 'string') {
|
|
93
|
+
return res.status(400).json({ error: 'Missing or invalid "code"' });
|
|
94
|
+
}
|
|
95
|
+
if (!name || typeof name !== 'string') {
|
|
96
|
+
return res.status(400).json({ error: 'Missing or invalid "name"' });
|
|
97
|
+
}
|
|
98
|
+
const limitCheck = await checkVerificationUserLimit(userId);
|
|
99
|
+
const { allowed, retryAfterSeconds, remaining, limit } = limitCheck;
|
|
100
|
+
if (!allowed) {
|
|
101
|
+
return res.status(429).json({
|
|
102
|
+
success: false,
|
|
103
|
+
error: 'Too many verifications',
|
|
104
|
+
message: 'Verification rate limit exceeded. Please try again later.',
|
|
105
|
+
retryAfterSeconds: retryAfterSeconds || 60,
|
|
106
|
+
remainingVerifications: remaining ?? 0,
|
|
107
|
+
limit
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const redis = getRedis();
|
|
111
|
+
if (!redis) {
|
|
112
|
+
return res.status(503).json({ error: 'Verification ticket unavailable' });
|
|
113
|
+
}
|
|
114
|
+
const verificationId = uuidv4();
|
|
115
|
+
const ticket = uuidv4();
|
|
116
|
+
const payload = {
|
|
117
|
+
code,
|
|
118
|
+
name,
|
|
119
|
+
userId,
|
|
120
|
+
verificationId,
|
|
121
|
+
requestId: req.requestId || null,
|
|
122
|
+
createdAt: new Date().toISOString()
|
|
123
|
+
};
|
|
124
|
+
await redis.set(VERIFY_TICKET_PREFIX + ticket, JSON.stringify(payload), 'EX', VERIFY_TICKET_TTL_SECONDS);
|
|
125
|
+
const expiresAt = new Date(Date.now() + VERIFY_TICKET_TTL_SECONDS * 1000).toISOString();
|
|
126
|
+
res.json({
|
|
127
|
+
success: true,
|
|
128
|
+
ticket,
|
|
129
|
+
verificationId,
|
|
130
|
+
expiresAt,
|
|
131
|
+
remainingVerifications: typeof remaining === 'number' ? remaining : undefined,
|
|
132
|
+
limit
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
next(error);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
72
138
|
|
|
73
139
|
// GET /: list user's computations (metadata only). Includes status, schedule; nextRun/lastRun when available from scheduler.
|
|
74
140
|
router.get('/', requireVerifiedUser, async (req, res, next) => {
|
|
@@ -3,18 +3,110 @@
|
|
|
3
3
|
* Backend verification remains the source of truth.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
6
8
|
const systemConfig = require('../../computation-system-v3/config/bulltrackers.config');
|
|
7
9
|
const { getForbiddenIdentifiers, getAllowedCtxKeys } = require('../../computation-system-v3/framework/core/manifest/AstValidator');
|
|
8
10
|
const { getRegistry } = require('../../computation-system-v3/framework/metadata/FunctionRegistry');
|
|
11
|
+
const { hashContract } = require('../../computation-system-v3/framework/metadata/ContractHash');
|
|
12
|
+
const { ALL_VERIFICATION_CODES, ALL_PRODUCTION_CODES, ERROR_CODE_DOC } = require('../../computation-system-v3/verification/ErrorCodes');
|
|
13
|
+
|
|
14
|
+
const SDK_VERSION = '2026-03-13';
|
|
15
|
+
const ERROR_DOC_PATH = path.join(__dirname, '../../computation-system-v3/docs/16-ERROR-CODE-REFERENCE.md');
|
|
16
|
+
|
|
17
|
+
function parseErrorCodeExamples() {
|
|
18
|
+
if (!fs.existsSync(ERROR_DOC_PATH)) return {};
|
|
19
|
+
try {
|
|
20
|
+
const content = fs.readFileSync(ERROR_DOC_PATH, 'utf8');
|
|
21
|
+
const lines = content.split('\n');
|
|
22
|
+
const examples = {};
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
if (!line.trim().startsWith('|')) continue;
|
|
25
|
+
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
|
26
|
+
if (cells.length < 3) continue;
|
|
27
|
+
const code = cells[0];
|
|
28
|
+
const example = cells[2];
|
|
29
|
+
if (code && code !== 'Code' && !/^-+$/.test(code) && example) {
|
|
30
|
+
examples[code] = example;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return examples;
|
|
34
|
+
} catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildSdk(registry) {
|
|
40
|
+
const modules = {};
|
|
41
|
+
const functionsById = registry.functionsById || {};
|
|
42
|
+
|
|
43
|
+
const fnList = Object.values(functionsById).sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
44
|
+
for (const fn of fnList) {
|
|
45
|
+
if (!fn || !fn.moduleName || !fn.functionName) continue;
|
|
46
|
+
if (!modules[fn.moduleName]) {
|
|
47
|
+
modules[fn.moduleName] = {
|
|
48
|
+
name: fn.moduleName,
|
|
49
|
+
moduleType: fn.moduleType || 'lib',
|
|
50
|
+
functions: []
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
modules[fn.moduleName].functions.push({
|
|
54
|
+
id: fn.id,
|
|
55
|
+
name: fn.functionName,
|
|
56
|
+
moduleType: fn.moduleType || 'lib',
|
|
57
|
+
description: fn.description || '',
|
|
58
|
+
example: fn.example || '',
|
|
59
|
+
scope: fn.scope,
|
|
60
|
+
tables: fn.tables || [],
|
|
61
|
+
fields: fn.fields || [],
|
|
62
|
+
lookback: fn.lookback || 0,
|
|
63
|
+
filterRequired: !!fn.filterRequired,
|
|
64
|
+
uses: fn.uses || '',
|
|
65
|
+
visibility: fn.visibility || 'public',
|
|
66
|
+
contract: fn.contract || null
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const mod of Object.values(modules)) {
|
|
71
|
+
mod.functions.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const wrapperFlat = Object.values(modules)
|
|
75
|
+
.filter(m => m.moduleType === 'wrapper')
|
|
76
|
+
.flatMap(m => (m.functions || []).map(f => f.name));
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
modules,
|
|
80
|
+
wrapperFlat
|
|
81
|
+
};
|
|
82
|
+
}
|
|
9
83
|
|
|
10
84
|
function buildUserComputationContract() {
|
|
11
85
|
const libModules = Array.isArray(systemConfig?.skills?.lib?.allowedModules)
|
|
12
86
|
? [...systemConfig.skills.lib.allowedModules]
|
|
13
87
|
: [];
|
|
14
88
|
const registry = getRegistry();
|
|
89
|
+
const errorExamples = parseErrorCodeExamples();
|
|
90
|
+
const sdk = buildSdk(registry);
|
|
15
91
|
|
|
16
|
-
|
|
17
|
-
|
|
92
|
+
const errorCodes = {
|
|
93
|
+
verification: [...ALL_VERIFICATION_CODES],
|
|
94
|
+
production: [...ALL_PRODUCTION_CODES],
|
|
95
|
+
docs: Object.fromEntries(
|
|
96
|
+
Object.entries(ERROR_CODE_DOC).map(([code, doc]) => [
|
|
97
|
+
code,
|
|
98
|
+
{
|
|
99
|
+
description: doc.description || '',
|
|
100
|
+
remediation: doc.remediation || '',
|
|
101
|
+
example: errorExamples[code] || ''
|
|
102
|
+
}
|
|
103
|
+
])
|
|
104
|
+
)
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const baseContract = {
|
|
108
|
+
version: '2026-03-13',
|
|
109
|
+
sdkVersion: SDK_VERSION,
|
|
18
110
|
exportShape: {
|
|
19
111
|
root: 'module.exports',
|
|
20
112
|
required: ['config', 'process']
|
|
@@ -45,9 +137,10 @@ function buildUserComputationContract() {
|
|
|
45
137
|
},
|
|
46
138
|
requiresTableKeys: Object.keys(systemConfig?.tables || {}),
|
|
47
139
|
metadata: {
|
|
48
|
-
generatedAt: registry.generatedAt,
|
|
49
140
|
modules: Object.keys(registry.modules || {}),
|
|
50
|
-
functions: Object.values(registry.functionsById || {})
|
|
141
|
+
functions: Object.values(registry.functionsById || {})
|
|
142
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)))
|
|
143
|
+
.map((f) => ({
|
|
51
144
|
id: f.id,
|
|
52
145
|
moduleType: f.moduleType,
|
|
53
146
|
moduleName: f.moduleName,
|
|
@@ -58,9 +151,31 @@ function buildUserComputationContract() {
|
|
|
58
151
|
lookback: f.lookback,
|
|
59
152
|
filterRequired: f.filterRequired,
|
|
60
153
|
uses: f.uses,
|
|
154
|
+
description: f.description || '',
|
|
155
|
+
example: f.example || '',
|
|
61
156
|
visibility: f.visibility
|
|
62
157
|
}))
|
|
63
|
-
}
|
|
158
|
+
},
|
|
159
|
+
sdk: {
|
|
160
|
+
modules: sdk.modules,
|
|
161
|
+
wrapperFlat: sdk.wrapperFlat,
|
|
162
|
+
ctx: { allowedKeys: Array.from(getAllowedCtxKeys()) },
|
|
163
|
+
security: { forbiddenIdentifiers: Array.from(getForbiddenIdentifiers()) },
|
|
164
|
+
tables: Object.keys(systemConfig?.tables || {})
|
|
165
|
+
},
|
|
166
|
+
errorCodes
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const contractHash = hashContract(baseContract);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
...baseContract,
|
|
173
|
+
metadata: {
|
|
174
|
+
...baseContract.metadata,
|
|
175
|
+
generatedAt: registry.generatedAt
|
|
176
|
+
},
|
|
177
|
+
generatedAt: registry.generatedAt,
|
|
178
|
+
contractHash
|
|
64
179
|
};
|
|
65
180
|
}
|
|
66
181
|
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Verification WebSocket server for ticketed sandbox logs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { WebSocketServer } = require('ws');
|
|
7
|
+
const { getAuth } = require('firebase-admin/auth');
|
|
8
|
+
const { z } = require('zod');
|
|
9
|
+
const { Manifest } = require('../../computation-system-v3/framework/core/Manifest');
|
|
10
|
+
const { runVerification } = require('../../computation-system-v3/verification/runVerification');
|
|
11
|
+
const { getRedis } = require('../middleware/upstashRateLimit');
|
|
12
|
+
const { isDevTenantRequest } = require('../middleware/identity');
|
|
13
|
+
const { initialize } = require('../core-api');
|
|
14
|
+
|
|
15
|
+
const VERIFY_TICKET_PREFIX = 'verify:ticket:';
|
|
16
|
+
const VERIFY_WS_PATH = '/computations/verify/ws';
|
|
17
|
+
const MAX_LOG_BYTES = 1024 * 1024;
|
|
18
|
+
const MAX_MESSAGES_PER_SECOND = 50;
|
|
19
|
+
|
|
20
|
+
const LogSchema = z.object({
|
|
21
|
+
level: z.enum(['info', 'warn', 'error', 'system']),
|
|
22
|
+
text: z.string().max(2000),
|
|
23
|
+
timestamp: z.string()
|
|
24
|
+
});
|
|
25
|
+
const StageSchema = z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
status: z.enum(['passed', 'failed', 'running', 'pending']),
|
|
28
|
+
message: z.string().optional(),
|
|
29
|
+
errorCode: z.string().optional()
|
|
30
|
+
});
|
|
31
|
+
const FinalSchema = z.object({
|
|
32
|
+
passed: z.boolean(),
|
|
33
|
+
errorCodes: z.array(z.string()).optional(),
|
|
34
|
+
errors: z.array(z.string()).optional()
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
async function resolveFirebaseUser(req) {
|
|
38
|
+
const authHeader = req.headers.authorization;
|
|
39
|
+
const testAuthHeader = req.headers['x-test-firebase-user'];
|
|
40
|
+
const allowTestAuth = process.env.ENABLE_TEST_AUTH === 'true' || process.env.NODE_ENV === 'test';
|
|
41
|
+
|
|
42
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
43
|
+
const token = authHeader.split('Bearer ')[1];
|
|
44
|
+
try {
|
|
45
|
+
const decodedToken = await getAuth().verifyIdToken(token);
|
|
46
|
+
return {
|
|
47
|
+
uid: decodedToken.uid,
|
|
48
|
+
email: decodedToken.email || null,
|
|
49
|
+
emailVerified: decodedToken.email_verified || false
|
|
50
|
+
};
|
|
51
|
+
} catch (_) {
|
|
52
|
+
if (!allowTestAuth) return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (allowTestAuth && testAuthHeader) {
|
|
57
|
+
try {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(testAuthHeader);
|
|
61
|
+
} catch {
|
|
62
|
+
parsed = testAuthHeader;
|
|
63
|
+
}
|
|
64
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
65
|
+
return {
|
|
66
|
+
uid: String(parsed.uid),
|
|
67
|
+
email: parsed.email || null,
|
|
68
|
+
emailVerified: true
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const val = String(parsed);
|
|
72
|
+
return {
|
|
73
|
+
uid: val.includes('@') ? 'mock_uid' : val,
|
|
74
|
+
email: val.includes('@') ? val : `user${val}@test.com`,
|
|
75
|
+
emailVerified: true
|
|
76
|
+
};
|
|
77
|
+
} catch (_) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveTargetUserId(firebaseUser, userCid, req) {
|
|
86
|
+
const isDev = isDevTenantRequest(req);
|
|
87
|
+
if (isDev) {
|
|
88
|
+
if (userCid && String(userCid).startsWith('dev-')) {
|
|
89
|
+
return String(userCid);
|
|
90
|
+
}
|
|
91
|
+
const baseId = userCid || firebaseUser?.uid || 'anonymous';
|
|
92
|
+
return `dev_${baseId}`;
|
|
93
|
+
}
|
|
94
|
+
return userCid || firebaseUser?.uid || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildWsUrlBase(req) {
|
|
98
|
+
const host = req.headers.host || 'localhost';
|
|
99
|
+
return `http://${host}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function attachVerificationWebSocketServer(server, dependencies, apiConfig = {}) {
|
|
103
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
104
|
+
let servicesPromise = null;
|
|
105
|
+
|
|
106
|
+
function getServices() {
|
|
107
|
+
if (!servicesPromise) {
|
|
108
|
+
servicesPromise = initialize({
|
|
109
|
+
bigquery: dependencies.bigquery,
|
|
110
|
+
firestore: dependencies.db,
|
|
111
|
+
storage: dependencies.storage,
|
|
112
|
+
pubsub: dependencies.pubsub,
|
|
113
|
+
logger: dependencies.logger
|
|
114
|
+
}, apiConfig);
|
|
115
|
+
}
|
|
116
|
+
return servicesPromise;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
120
|
+
try {
|
|
121
|
+
const url = new URL(req.url, buildWsUrlBase(req));
|
|
122
|
+
if (url.pathname !== VERIFY_WS_PATH) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
126
|
+
wss.emit('connection', ws, req);
|
|
127
|
+
});
|
|
128
|
+
} catch (e) {
|
|
129
|
+
socket.destroy();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
wss.on('connection', async (ws, req) => {
|
|
134
|
+
let totalBytes = 0;
|
|
135
|
+
let msgWindowStart = Date.now();
|
|
136
|
+
let msgCount = 0;
|
|
137
|
+
const sendJson = (payload) => {
|
|
138
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
if (now - msgWindowStart >= 1000) {
|
|
141
|
+
msgWindowStart = now;
|
|
142
|
+
msgCount = 0;
|
|
143
|
+
}
|
|
144
|
+
msgCount += 1;
|
|
145
|
+
if (msgCount > MAX_MESSAGES_PER_SECOND) {
|
|
146
|
+
ws.close(1008, 'Rate limit exceeded');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const json = JSON.stringify(payload);
|
|
150
|
+
totalBytes += Buffer.byteLength(json, 'utf8');
|
|
151
|
+
if (totalBytes > MAX_LOG_BYTES) {
|
|
152
|
+
try {
|
|
153
|
+
ws.send(JSON.stringify({
|
|
154
|
+
type: 'log',
|
|
155
|
+
level: 'system',
|
|
156
|
+
text: 'Log output exceeded allowed size. Closing connection.',
|
|
157
|
+
timestamp: new Date().toISOString()
|
|
158
|
+
}));
|
|
159
|
+
} catch (_) { }
|
|
160
|
+
ws.close(1008, 'Log quota exceeded');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
ws.send(json);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
ws.on('message', () => {
|
|
167
|
+
ws.close(1008, 'Read-only verification stream');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const url = new URL(req.url, buildWsUrlBase(req));
|
|
172
|
+
const ticket = url.searchParams.get('ticket');
|
|
173
|
+
if (!ticket) {
|
|
174
|
+
ws.close(1008, 'Missing ticket');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const redis = getRedis();
|
|
179
|
+
if (!redis) {
|
|
180
|
+
ws.close(1011, 'Ticket store unavailable');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const raw = await redis.get(VERIFY_TICKET_PREFIX + ticket);
|
|
185
|
+
if (!raw) {
|
|
186
|
+
ws.close(1008, 'Invalid or expired ticket');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
await redis.del(VERIFY_TICKET_PREFIX + ticket);
|
|
190
|
+
|
|
191
|
+
const payload = JSON.parse(raw);
|
|
192
|
+
const firebaseUser = await resolveFirebaseUser(req);
|
|
193
|
+
if (!firebaseUser) {
|
|
194
|
+
ws.close(1008, 'Authentication required');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const services = await getServices();
|
|
199
|
+
let userCid = null;
|
|
200
|
+
if (firebaseUser.email) {
|
|
201
|
+
try {
|
|
202
|
+
const lookup = await services.authService.lookupCidByEmail(firebaseUser.email, { isDevTenant: isDevTenantRequest(req) });
|
|
203
|
+
if (lookup?.cid != null) userCid = String(lookup.cid);
|
|
204
|
+
} catch (_) { }
|
|
205
|
+
}
|
|
206
|
+
const targetUserId = resolveTargetUserId(firebaseUser, userCid, req);
|
|
207
|
+
if (!targetUserId || targetUserId !== payload.userId) {
|
|
208
|
+
ws.close(1008, 'Ticket user mismatch');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const verificationId = payload.verificationId || '';
|
|
213
|
+
const startedAt = new Date().toISOString();
|
|
214
|
+
const auditRef = services.db
|
|
215
|
+
? services.db.collection('users').doc(targetUserId)
|
|
216
|
+
.collection('verification_sessions').doc(verificationId)
|
|
217
|
+
: null;
|
|
218
|
+
|
|
219
|
+
if (auditRef) {
|
|
220
|
+
await auditRef.set({
|
|
221
|
+
verificationId,
|
|
222
|
+
userId: targetUserId,
|
|
223
|
+
name: payload.name || null,
|
|
224
|
+
startedAt,
|
|
225
|
+
requestId: payload.requestId || null,
|
|
226
|
+
status: 'running'
|
|
227
|
+
}, { merge: true });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const stageEmitter = (stagePayload) => {
|
|
231
|
+
const parsed = StageSchema.safeParse(stagePayload);
|
|
232
|
+
if (!parsed.success) {
|
|
233
|
+
sendJson({
|
|
234
|
+
type: 'log',
|
|
235
|
+
level: 'system',
|
|
236
|
+
text: 'Verification emitted an invalid stage update.',
|
|
237
|
+
timestamp: new Date().toISOString()
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
sendJson({ type: 'stage', ...parsed.data });
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const logEmitter = (entry) => {
|
|
245
|
+
let text = String(entry.message || '');
|
|
246
|
+
if (text.length > 2000) text = text.slice(0, 2000);
|
|
247
|
+
const logPayload = {
|
|
248
|
+
level: entry.level === 'info' || entry.level === 'warn' || entry.level === 'error' ? entry.level : 'info',
|
|
249
|
+
text,
|
|
250
|
+
timestamp: new Date(entry.ts || Date.now()).toISOString()
|
|
251
|
+
};
|
|
252
|
+
const parsed = LogSchema.safeParse(logPayload);
|
|
253
|
+
if (!parsed.success) {
|
|
254
|
+
sendJson({
|
|
255
|
+
type: 'log',
|
|
256
|
+
level: 'system',
|
|
257
|
+
text: 'Verification emitted an invalid log entry.',
|
|
258
|
+
timestamp: new Date().toISOString()
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
sendJson({ type: 'log', ...parsed.data });
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const userComputationNames = [];
|
|
266
|
+
try {
|
|
267
|
+
const ref = services.db.collection('users').doc(targetUserId).collection('computations');
|
|
268
|
+
const snap = await ref.get();
|
|
269
|
+
snap.docs.forEach(d => {
|
|
270
|
+
const n = d.data()?.name || d.id;
|
|
271
|
+
if (n) userComputationNames.push(Manifest.normalize(n));
|
|
272
|
+
});
|
|
273
|
+
} catch (_) { }
|
|
274
|
+
|
|
275
|
+
const result = await runVerification(payload.code, payload.name, targetUserId, {
|
|
276
|
+
userComputationNames,
|
|
277
|
+
logger: services.logger || console,
|
|
278
|
+
uploadSessionId: verificationId || 'verify-session',
|
|
279
|
+
onStageProgress: (_sid, stage) => stageEmitter(stage),
|
|
280
|
+
onLog: (entry) => logEmitter(entry)
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const finalPayload = {
|
|
284
|
+
passed: result.passed === true,
|
|
285
|
+
errorCodes: result.errorCodes || undefined,
|
|
286
|
+
errors: result.errors || undefined
|
|
287
|
+
};
|
|
288
|
+
const finalParsed = FinalSchema.safeParse(finalPayload);
|
|
289
|
+
if (finalParsed.success) {
|
|
290
|
+
sendJson({ type: 'final', ...finalParsed.data });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (auditRef) {
|
|
294
|
+
await auditRef.set({
|
|
295
|
+
finishedAt: new Date().toISOString(),
|
|
296
|
+
status: result.passed === true ? 'passed' : 'failed',
|
|
297
|
+
errorCodes: result.errorCodes || null
|
|
298
|
+
}, { merge: true });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
ws.close(1000, 'Verification complete');
|
|
302
|
+
} catch (err) {
|
|
303
|
+
try {
|
|
304
|
+
ws.send(JSON.stringify({
|
|
305
|
+
type: 'log',
|
|
306
|
+
level: 'system',
|
|
307
|
+
text: 'Verification failed unexpectedly.',
|
|
308
|
+
timestamp: new Date().toISOString()
|
|
309
|
+
}));
|
|
310
|
+
} catch (_) { }
|
|
311
|
+
ws.close(1011, 'Verification error');
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
return wss;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = {
|
|
319
|
+
attachVerificationWebSocketServer
|
|
320
|
+
};
|
|
@@ -20,3 +20,4 @@ One-line description and primary audience for each doc. Source of truth: codebas
|
|
|
20
20
|
| [13-OBSERVABILITY-LOG-SINKS](13-OBSERVABILITY-LOG-SINKS.md) | Telemetry sink implementation, Logs Explorer and BigQuery query recipes, revision-based charting and dashboard-as-code workflow | Developer, ops |
|
|
21
21
|
| [14-HOW-EXECUTION-WORKS](14-HOW-EXECUTION-WORKS.md) | Dependencies and execution order (DAG), what runs when, planner and dispatcher | Users, developer |
|
|
22
22
|
| [15-CREDITS-AND-BILLING](15-CREDITS-AND-BILLING.md) | How credits are consumed, balance and negative balance, cost of 7-day overwrite | Users, developer |
|
|
23
|
+
| [17-IDE-VERIFICATION-PLAN](17-IDE-VERIFICATION-PLAN.md) | Studio IDE DX plan: templates, IntelliSense, verification terminal, websocket security | Developer, frontend, ops |
|