@getopencrawl/sdk 0.0.0-stage → 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/README.md +598 -2
- package/dist/agentPlatform.d.ts +142 -0
- package/dist/agentPlatform.d.ts.map +1 -0
- package/dist/agentPlatform.js +46 -0
- package/dist/agentPlatform.js.map +1 -0
- package/dist/agents.d.ts +237 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +279 -0
- package/dist/agents.js.map +1 -0
- package/dist/answerContract.d.ts +85 -0
- package/dist/answerContract.d.ts.map +1 -0
- package/dist/answerContract.js +112 -0
- package/dist/answerContract.js.map +1 -0
- package/dist/answerView.d.ts +40 -0
- package/dist/answerView.d.ts.map +1 -0
- package/dist/answerView.js +118 -0
- package/dist/answerView.js.map +1 -0
- package/dist/configuration.d.ts +5 -0
- package/dist/configuration.d.ts.map +1 -0
- package/dist/configuration.js +29 -0
- package/dist/configuration.js.map +1 -0
- package/dist/customers.d.ts +176 -0
- package/dist/customers.d.ts.map +1 -0
- package/dist/customers.js +286 -0
- package/dist/customers.js.map +1 -0
- package/dist/errors.d.ts +35 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +58 -0
- package/dist/errors.js.map +1 -0
- package/dist/eventEvidence.d.ts +10 -0
- package/dist/eventEvidence.d.ts.map +1 -0
- package/dist/eventEvidence.js +52 -0
- package/dist/eventEvidence.js.map +1 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +139 -0
- package/dist/index.js.map +1 -0
- package/dist/productMonitors.d.ts +258 -0
- package/dist/productMonitors.d.ts.map +1 -0
- package/dist/productMonitors.js +68 -0
- package/dist/productMonitors.js.map +1 -0
- package/dist/researchAnswerContract.d.ts +8 -0
- package/dist/researchAnswerContract.d.ts.map +1 -0
- package/dist/researchAnswerContract.js +23 -0
- package/dist/researchAnswerContract.js.map +1 -0
- package/dist/researchAnswerView.d.ts +8 -0
- package/dist/researchAnswerView.d.ts.map +1 -0
- package/dist/researchAnswerView.js +27 -0
- package/dist/researchAnswerView.js.map +1 -0
- package/dist/researchCompletion.d.ts +114 -0
- package/dist/researchCompletion.d.ts.map +1 -0
- package/dist/researchCompletion.js +94 -0
- package/dist/researchCompletion.js.map +1 -0
- package/dist/serviceTenants.d.ts +15 -0
- package/dist/serviceTenants.d.ts.map +1 -0
- package/dist/serviceTenants.js +58 -0
- package/dist/serviceTenants.js.map +1 -0
- package/dist/tools.d.ts +59 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +60 -0
- package/dist/tools.js.map +1 -0
- package/dist/transport.d.ts +34 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +227 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +399 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/webhooks.d.ts +38 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +73 -0
- package/dist/webhooks.js.map +1 -0
- package/examples/letter-customer-research-demo.mjs +54 -0
- package/examples/letter-customer-research-store.d.mts +38 -0
- package/examples/letter-customer-research-store.mjs +186 -0
- package/examples/letter-customer-research.d.mts +39 -0
- package/examples/letter-customer-research.mjs +194 -0
- package/examples/letter-event-receiver.d.mts +10 -0
- package/examples/letter-event-receiver.mjs +55 -0
- package/examples/letter-event-store.d.mts +35 -0
- package/examples/letter-event-store.mjs +84 -0
- package/examples/letter-seer.d.mts +31 -0
- package/examples/letter-seer.mjs +78 -0
- package/examples/letter-service-tenants.d.mts +12 -0
- package/examples/letter-service-tenants.mjs +49 -0
- package/examples/research-webhook-receiver.mjs +29 -0
- package/package.json +75 -4
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/** Server-only Node24 recipe. The host supplies actual customer authorization,
|
|
2
|
+
* a private SQLite store, and an encrypted secret-store adapter. It does not
|
|
3
|
+
* install itself into Letter, provision tenants automatically, or run timers.
|
|
4
|
+
*/
|
|
5
|
+
import {clientForCustomer} from './letter-service-tenants.mjs';
|
|
6
|
+
import {validateAgentInput,validateAgentFeedbackInput} from '../dist/agents.js';
|
|
7
|
+
import {CustomerResearchError} from './letter-customer-research-store.mjs';
|
|
8
|
+
import {OpenCrawlAPIError} from '../dist/errors.js';
|
|
9
|
+
const fail=code=>{throw new CustomerResearchError(code)};
|
|
10
|
+
const clone=value=>JSON.parse(JSON.stringify(value));
|
|
11
|
+
|
|
12
|
+
/** A confirmed HTTP refusal, with finite local codes only. Never expose server
|
|
13
|
+
* prose, request IDs, response bodies, transport causes or credentials.
|
|
14
|
+
* An admission intent still needs its original receipt; this permits no replay.
|
|
15
|
+
*/
|
|
16
|
+
export class CustomerResearchRemoteError extends CustomerResearchError {
|
|
17
|
+
constructor(code,status){super(code);this.name='CustomerResearchRemoteError';this.status=status;}
|
|
18
|
+
}
|
|
19
|
+
function knownRemote(error){
|
|
20
|
+
if(!(error instanceof OpenCrawlAPIError))return null;
|
|
21
|
+
const code={400:'request_rejected',401:'authentication_required',403:'access_denied',
|
|
22
|
+
404:error.code==='source_not_retained'?'source_not_retained':'not_found',409:'conflict',
|
|
23
|
+
422:'request_rejected',429:'rate_limited'}[error.status];
|
|
24
|
+
return code?new CustomerResearchRemoteError(code,error.status):null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createLetterCustomerResearch({store,baseUrl,authorize,secrets,fetch}){
|
|
28
|
+
if(typeof authorize!=='function'||typeof secrets?.get!=='function')fail('host_adapters_required');
|
|
29
|
+
// Each operation has a bounded lease; no lock is held across network IO.
|
|
30
|
+
async function operation(context,customerId,options,fn){
|
|
31
|
+
const timeoutMs=options?.timeoutMs??30000;
|
|
32
|
+
if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1||timeoutMs>60000)fail('invalid_timeout');
|
|
33
|
+
const signal=AbortSignal.any([AbortSignal.timeout(timeoutMs),...(options?.signal?[options.signal]:[])]);
|
|
34
|
+
let claim;
|
|
35
|
+
const alive=()=>{if(signal.aborted)fail('deadline_or_abort')};
|
|
36
|
+
const bounded=promise=>new Promise((resolve,reject)=>{
|
|
37
|
+
const abort=()=>reject(new CustomerResearchError('deadline_or_abort'));
|
|
38
|
+
if(signal.aborted)return abort();signal.addEventListener('abort',abort,{once:true});
|
|
39
|
+
Promise.resolve(promise).then(resolve,reject).finally(()=>signal.removeEventListener('abort',abort));
|
|
40
|
+
});
|
|
41
|
+
async function allowed(){alive();if(await bounded(authorize(context,customerId))!==true)fail('customer_denied');alive();if(claim)store.check(claim)}
|
|
42
|
+
try{
|
|
43
|
+
await allowed();claim=store.claim(customerId,timeoutMs+5000);await allowed();
|
|
44
|
+
const mapping=claim.mapping;
|
|
45
|
+
// The mapping's secret reference is selected by trusted server setup. The
|
|
46
|
+
// request cannot choose a tenant or key. Recheck after asynchronous vault IO.
|
|
47
|
+
const key=await bounded(secrets.get(mapping.secretRef));await allowed();
|
|
48
|
+
if(typeof key!=='string'||key.length<8)fail('customer_key_unavailable');
|
|
49
|
+
const client=await clientForCustomer({baseUrl,authorizedTenantId:mapping.tenantId,secrets:{get:async()=>key},fetch});await allowed();
|
|
50
|
+
const safe=value=>{if(JSON.stringify(value).includes(key))fail('credential_in_data');return value};
|
|
51
|
+
const read=async fn=>{await allowed();const value=await bounded(fn({signal,timeoutMs}));await allowed();return safe(value)};
|
|
52
|
+
return await fn({claim,client,allowed,read,safe,bounded,signal,timeoutMs});
|
|
53
|
+
}catch(error){if(error instanceof CustomerResearchError)throw error;throw knownRemote(error)??new CustomerResearchError('operation_unconfirmed')}
|
|
54
|
+
finally{if(claim)store.release(claim)}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
/** Exactly one initial create per mapping. An intent is committed before POST,
|
|
58
|
+
* even if the process dies before sending. Never call start to recover it.
|
|
59
|
+
*/
|
|
60
|
+
async start(context,customerId,input,options={}){
|
|
61
|
+
// Snapshot caller input before awaits; use this exact SDK's normalization.
|
|
62
|
+
let brief;try{brief=clone(validateAgentInput(input))}catch{fail('invalid_brief')}
|
|
63
|
+
return operation(context,customerId,options,async({claim,client,allowed,safe,bounded,signal,timeoutMs})=>{
|
|
64
|
+
safe(brief);await allowed();const intent=store.begin(claim,brief);
|
|
65
|
+
try{await allowed();const value=await bounded(client.agents.create(intent.brief,{idempotencyKey:intent.key,signal,timeoutMs}));await allowed();safe(value);return store.accept(claim,value)}
|
|
66
|
+
catch(error){const known=knownRemote(error);if(known)throw known;fail('admission_unconfirmed_use_recovery')}
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
/** GET only. Missing/denied/malformed receipts leave the original intent intact.
|
|
70
|
+
* A recovered receipt is historical; read its exact run for current status.
|
|
71
|
+
*/
|
|
72
|
+
async recover(context,customerId,options={}){
|
|
73
|
+
return operation(context,customerId,options,async({claim,client,read})=>{
|
|
74
|
+
const intent=store.intent(customerId);if(!intent)fail('missing_intent');
|
|
75
|
+
const value=await read(opts=>client.agents.receipt('create',{...opts,idempotencyKey:intent.key}));
|
|
76
|
+
return store.accept(claim,value);
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
/** One deliberate repeat of the known latest terminal run. Its ID anchors
|
|
80
|
+
* duplicate actions. A witnessed refusal needs explicit recovery before a
|
|
81
|
+
* new deliberate repeat can use the same predecessor.
|
|
82
|
+
*/
|
|
83
|
+
async rerun(context,customerId,previousRunId,options={}){
|
|
84
|
+
if(typeof previousRunId!=='string'||!/^job_[0-9a-f]{32}$/.test(previousRunId))fail('unknown_run');
|
|
85
|
+
return operation(context,customerId,options,async({claim,client,allowed,read,safe,bounded,signal,timeoutMs})=>{
|
|
86
|
+
const agentId=store.mapping(customerId).agentId;
|
|
87
|
+
if(!agentId||!store.knowsRun(customerId,previousRunId))fail('unknown_run');
|
|
88
|
+
if(store.rerunIntent(customerId,previousRunId))fail('rerun_exists_use_recovery');
|
|
89
|
+
const agent=await read(opts=>client.agents.get(agentId,opts));
|
|
90
|
+
const intent=store.beginRerun(claim,previousRunId,agent);
|
|
91
|
+
try{await allowed();const value=await bounded(client.agents.runs.create(agentId,{expected_revision:intent.expectedRevision},{idempotencyKey:intent.key,signal,timeoutMs}));await allowed();safe(value);return store.acceptRerun(claim,previousRunId,value)}
|
|
92
|
+
catch(error){const known=knownRemote(error);if(known){if([409,429].includes(known.status)){await allowed();store.refuseRerun(claim,previousRunId,intent.key,known.status)}throw known}fail('rerun_unconfirmed_use_recovery')}
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
/** Recover the original receipt, or explicitly retire a witnessed refusal
|
|
96
|
+
* after a 404 and a fresh authorized agent read. No POST or replacement key.
|
|
97
|
+
*/
|
|
98
|
+
async recoverRerun(context,customerId,previousRunId,options={}){
|
|
99
|
+
if(typeof previousRunId!=='string'||!/^job_[0-9a-f]{32}$/.test(previousRunId))fail('unknown_run');
|
|
100
|
+
return operation(context,customerId,options,async({claim,client,read})=>{
|
|
101
|
+
const intent=store.rerunIntent(customerId,previousRunId)??store.retiredRerun(customerId,previousRunId);
|
|
102
|
+
if(!intent)fail('missing_rerun_intent');
|
|
103
|
+
if(intent.agentId!==store.mapping(customerId).agentId||!store.knowsRun(customerId,previousRunId))fail('rerun_scope_mismatch');
|
|
104
|
+
let value;
|
|
105
|
+
try{value=await read(opts=>client.agents.receipt('run:'+intent.agentId,{...opts,idempotencyKey:intent.key}))}
|
|
106
|
+
catch(error){
|
|
107
|
+
if(!(error instanceof OpenCrawlAPIError)||error.status!==404||![409,429].includes(intent.refusalStatus)||intent.receiptHash||intent.runId)throw error;
|
|
108
|
+
const agent=await read(opts=>client.agents.get(intent.agentId,opts));
|
|
109
|
+
return store.retireRerun(claim,previousRunId,intent.key,agent);
|
|
110
|
+
}
|
|
111
|
+
return store.acceptRerun(claim,previousRunId,value);
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
/** Current remote authority, never cached evidence on failure. The caller can
|
|
115
|
+
* select only an exact run already admitted/discovered for this mapped agent.
|
|
116
|
+
*/
|
|
117
|
+
async read(context,customerId,runId,options={}){
|
|
118
|
+
return operation(context,customerId,options,async({claim,client,read})=>{
|
|
119
|
+
const agentId=store.mapping(customerId).agentId;
|
|
120
|
+
if(!agentId||!store.knowsRun(customerId,runId))fail('unknown_run');
|
|
121
|
+
const run=await read(opts=>client.agents.runs.get(agentId,runId,opts));store.saveRun(claim,run);return run;
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
/** One current-access read of retained context for a known run. The SDK
|
|
125
|
+
* validates source identity and text hash; no source text is cached locally.
|
|
126
|
+
*/
|
|
127
|
+
async source(context,customerId,runId,sourceId,options={}){
|
|
128
|
+
return operation(context,customerId,options,async({client,read})=>{
|
|
129
|
+
const agentId=store.mapping(customerId).agentId;
|
|
130
|
+
if(!agentId||!store.knowsRun(customerId,runId))fail('unknown_run');
|
|
131
|
+
return read(opts=>client.agents.runs.source(agentId,runId,sourceId,opts));
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
/** Current schedule for this mapping's agent; no caller-selected agent ID. */
|
|
135
|
+
async schedule(context,customerId,options={}){
|
|
136
|
+
return operation(context,customerId,options,async({client,read})=>{
|
|
137
|
+
const agentId=store.mapping(customerId).agentId;if(!agentId)fail('missing_agent');
|
|
138
|
+
return read(opts=>client.agents.schedule.get(agentId,opts));
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
/** One explicit revision-checked write. On conflict or uncertainty, call
|
|
142
|
+
* schedule() to reconcile; never replay this mutation automatically.
|
|
143
|
+
*/
|
|
144
|
+
async setSchedule(context,customerId,input,options={}){
|
|
145
|
+
if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(key=>!['expected_revision','enabled'].includes(key)))fail('invalid_schedule');
|
|
146
|
+
const body={expected_revision:input.expected_revision,enabled:input.enabled};
|
|
147
|
+
if(!Number.isSafeInteger(body.expected_revision)||body.expected_revision<0||typeof body.enabled!=='boolean')fail('invalid_schedule');
|
|
148
|
+
return operation(context,customerId,options,async({client,read})=>{
|
|
149
|
+
const agentId=store.mapping(customerId).agentId;if(!agentId)fail('missing_agent');
|
|
150
|
+
return read(opts=>client.agents.schedule.update(agentId,body,opts));
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
/** Current authorized feedback for one finding; never a cached preference. */
|
|
154
|
+
async feedback(context,customerId,runId,findingId,options={}){
|
|
155
|
+
return operation(context,customerId,options,async({client,read})=>{
|
|
156
|
+
const agentId=store.mapping(customerId).agentId;
|
|
157
|
+
if(!agentId||!store.knowsRun(customerId,runId))fail('unknown_run');
|
|
158
|
+
return read(opts=>client.agents.runs.feedback.get(agentId,runId,findingId,opts));
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
/** One CAS write, no replay. On uncertainty read feedback before deciding. */
|
|
162
|
+
async setFeedback(context,customerId,runId,findingId,input,options={}){
|
|
163
|
+
let body;try{body=validateAgentFeedbackInput(input)}catch{fail('invalid_feedback')}
|
|
164
|
+
return operation(context,customerId,options,async({client,read,safe})=>{
|
|
165
|
+
const agentId=store.mapping(customerId).agentId;
|
|
166
|
+
if(!agentId||!store.knowsRun(customerId,runId))fail('unknown_run');
|
|
167
|
+
safe(body);
|
|
168
|
+
return read(opts=>client.agents.runs.feedback.set(agentId,runId,findingId,body,opts));
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
async deleteFeedback(context,customerId,runId,findingId,expectedRevision,options={}){
|
|
172
|
+
return operation(context,customerId,options,async({client,read})=>{
|
|
173
|
+
const agentId=store.mapping(customerId).agentId;
|
|
174
|
+
if(!agentId||!store.knowsRun(customerId,runId))fail('unknown_run');
|
|
175
|
+
return read(opts=>client.agents.runs.feedback.delete(agentId,runId,findingId,expectedRevision,opts));
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
/** One page + up to25 pending reads, no auto-pagination/admission/recurrence.
|
|
179
|
+
* Atomic rows+pending+cursor, then atomic exact updates. A failed read retains
|
|
180
|
+
* its pending ID. A page failure never advances its checkpoint.
|
|
181
|
+
*/
|
|
182
|
+
async poll(context,customerId,options={}){
|
|
183
|
+
return operation(context,customerId,options,async({claim,client,read})=>{
|
|
184
|
+
const agentId=store.mapping(customerId).agentId;if(!agentId)fail('missing_agent');
|
|
185
|
+
const cursor=store.cursor(customerId);
|
|
186
|
+
const page=await read(opts=>client.agents.runs.list(agentId,{...opts,limit:25,...(cursor===undefined?{}:{cursor})}));
|
|
187
|
+
store.commitPage(claim,page);
|
|
188
|
+
let checked=0;const observed=new Set(page.items.map(run=>run.id));
|
|
189
|
+
for(const runId of store.pending(customerId)){const run=await read(opts=>client.agents.runs.get(agentId,runId,opts));store.saveRun(claim,run);observed.add(run.id);checked++}
|
|
190
|
+
return {received:page.items.length,checkedPending:checked,hasMoreAdmissions:page.has_more,pending:store.pendingCount(customerId),observedRunIds:[...observed]};
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type {Fetch} from '@getopencrawl/sdk';
|
|
2
|
+
import type {LetterEvidenceStore} from './letter-event-store.mjs';
|
|
3
|
+
|
|
4
|
+
/** Choose mappingId from server routing, not a request body/customer-supplied key. */
|
|
5
|
+
export function createLetterEventReceiver(options:{
|
|
6
|
+
store:LetterEvidenceStore;mappingId:string;baseUrl:string;
|
|
7
|
+
secrets:{get:(tenantId:string)=>Promise<string|null|undefined>};
|
|
8
|
+
webhookSecrets:{get:(signingSecretRef:string)=>Promise<string|null|undefined>};
|
|
9
|
+
fetch?:Fetch;
|
|
10
|
+
}):(request:Request)=>Promise<Response>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Server-only qualified evidence handoff. The optional SQLite adapter requires Node24. */
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {createResearchWebhookReceiver} from './research-webhook-receiver.mjs';
|
|
4
|
+
import {clientForCustomer} from './letter-service-tenants.mjs';
|
|
5
|
+
|
|
6
|
+
const kinds=['baseline','changed','unavailable','recovered'];
|
|
7
|
+
const id=value=>typeof value==='string'&&value.length>0&&value.length<=200;
|
|
8
|
+
const stamp=value=>typeof value==='string'&&value.length<=128&&Number.isFinite(Date.parse(value));
|
|
9
|
+
const canonical=value=>JSON.stringify(value,(_key,v)=>v&&typeof v==='object'&&!Array.isArray(v)?Object.fromEntries(Object.keys(v).sort().map(k=>[k,v[k]])):v);
|
|
10
|
+
const digest=value=>createHash('sha256').update(canonical(value)).digest('hex');
|
|
11
|
+
function observation(event){
|
|
12
|
+
if(event.type==='webhook.test')return null;
|
|
13
|
+
const kind=event.type.slice('research.'.length),data=event.data;
|
|
14
|
+
if(!kinds.includes(kind)||data.observation_id!==event.id||!stamp(event.created_at)||data.observed_at!==event.created_at||!(data.previous_observed_at===null||stamp(data.previous_observed_at))||typeof data.gap!=='boolean'||![data.before_version_id,data.after_version_id].every(v=>v===null||id(v)))throw new Error('Invalid observation identity.');
|
|
15
|
+
return {id:event.id,collectionId:event.collection_id,kind,observedAt:data.observed_at,previousObservedAt:data.previous_observed_at,gap:data.gap,beforeVersionId:data.before_version_id,afterVersionId:data.after_version_id};
|
|
16
|
+
}
|
|
17
|
+
function bind(detail,event){
|
|
18
|
+
if(detail.id!==event.id||detail.collection_id!==event.collectionId||detail.kind!==event.kind||detail.observed_at!==event.observedAt||detail.previous_observed_at!==event.previousObservedAt||detail.gap!==event.gap||(detail.before?.version_id??null)!==event.beforeVersionId||(detail.after?.version_id??null)!==event.afterVersionId)throw new Error('Retained observation does not match its notification.');
|
|
19
|
+
return detail;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The mapping ID is chosen by server routing, never from the request body.
|
|
23
|
+
* secrets.get(tenantId) supplies only that customer's child key. webhookSecrets
|
|
24
|
+
* resolves the mapping's signing-secret reference. Neither secret is persisted
|
|
25
|
+
* in inbox/evidence rows or included in responses. No parent-key fallback.
|
|
26
|
+
*/
|
|
27
|
+
export function createLetterEventReceiver({store,mappingId,baseUrl,secrets,webhookSecrets,fetch}){
|
|
28
|
+
if(!id(mappingId)||!store||typeof secrets?.get!=='function'||typeof webhookSecrets?.get!=='function')throw new Error('A durable store and server-owned mapping are required.');
|
|
29
|
+
return async request=>{
|
|
30
|
+
if(request.method!=='POST')return new Response(null,{status:405,headers:{Allow:'POST'}});
|
|
31
|
+
let mapping,secret;
|
|
32
|
+
try{mapping=store.mapping(mappingId);if(!mapping?.active)return new Response(null,{status:404});secret=await webhookSecrets.get(mapping.signingSecretRef);if(!secret)throw new Error('Unavailable signing secret.');}catch{return new Response(null,{status:503});}
|
|
33
|
+
return createResearchWebhookReceiver({secret,collectionId:mapping.collectionId,processOnce:async({deliveryId,event})=>{
|
|
34
|
+
const selected=observation(event),claim=store.claim(mapping,deliveryId,event.id,digest(event),selected);
|
|
35
|
+
if(claim.state==='duplicate')return;
|
|
36
|
+
if(claim.state!=='claimed')throw new Error('Observation processing is not ready.');
|
|
37
|
+
try{
|
|
38
|
+
if(selected===null){store.commit(claim,{type:'connection_test',signalClassification:'not_assessed'});return;}
|
|
39
|
+
const child=await clientForCustomer({baseUrl,authorizedTenantId:mapping.tenantId,secrets,fetch});
|
|
40
|
+
if(!store.same(mapping))throw new Error('Customer mapping changed before the evidence read.');
|
|
41
|
+
const detail=bind(await child.collections.event(mapping.collectionId,event.id,{budget:16000}),selected);
|
|
42
|
+
store.commit(claim,{type:'retained_observation',signalClassification:'not_assessed',coverage:'bounded_window',
|
|
43
|
+
interpretation:'Source observation only; no independently verified business event, buying intent or capability change.',
|
|
44
|
+
evidence:detail});
|
|
45
|
+
}catch(error){
|
|
46
|
+
// A lost commit acknowledgement may already have a durable record. Reconcile
|
|
47
|
+
// its exact claim before any retry, never issue a replacement record/read.
|
|
48
|
+
if(store.committed(claim))return;
|
|
49
|
+
const unavailable=[401,403,404].includes(error?.status);
|
|
50
|
+
const terminal=store.fail(claim,unavailable?'unavailable':'retryable');
|
|
51
|
+
if(!unavailable||!terminal)throw new Error('Observation processing could not be confirmed.');
|
|
52
|
+
}
|
|
53
|
+
}})(request);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type {ResearchEventDetail} from '@getopencrawl/sdk';
|
|
2
|
+
|
|
3
|
+
export type LetterMappingInput={id:string;customerId:string;tenantId:string;collectionId:string;signingSecretRef:string;active:boolean};
|
|
4
|
+
export type LetterMapping=LetterMappingInput&{revision:number};
|
|
5
|
+
export type LetterObservation={id:string;collectionId:string;kind:ResearchEventDetail['kind'];observedAt:string;previousObservedAt:string|null;gap:boolean;beforeVersionId:string|null;afterVersionId:string|null};
|
|
6
|
+
export type LetterEvidenceRecord=
|
|
7
|
+
|{type:'connection_test';signalClassification:'not_assessed'}
|
|
8
|
+
|{type:'retained_observation';signalClassification:'not_assessed';coverage:'bounded_window';interpretation:string;evidence:ResearchEventDetail};
|
|
9
|
+
export type LetterClaim={state:'claimed';mapping:LetterMapping;deliveryId:string;eventId:string;payloadHash:string;token:string;observation:LetterObservation|null};
|
|
10
|
+
export type LetterClaimResult=LetterClaim|{state:'duplicate'|'busy'};
|
|
11
|
+
export type LetterStoredRecord={state:'processing'|'committed'|'unavailable'|'retryable';record:LetterEvidenceRecord|null;updatedAt:number};
|
|
12
|
+
/** Synchronous transactions; never hold a transaction across remote I/O. */
|
|
13
|
+
export interface LetterEvidenceStore{
|
|
14
|
+
mapping(id:string):LetterMapping|null;
|
|
15
|
+
same(mapping:LetterMapping):boolean;
|
|
16
|
+
claim(mapping:LetterMapping,deliveryId:string,eventId:string,payloadHash:string,observation:LetterObservation|null):LetterClaimResult;
|
|
17
|
+
committed(claim:LetterClaim):boolean;
|
|
18
|
+
commit(claim:LetterClaim,record:LetterEvidenceRecord):void;
|
|
19
|
+
/** True only when an exact terminal unavailable receipt is durably confirmed. */
|
|
20
|
+
fail(claim:LetterClaim,state:'unavailable'|'retryable'):boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Optional server-only adapter, Node24+. Use private directory0700/file0600. */
|
|
23
|
+
export class LetterEventStore implements LetterEvidenceStore{
|
|
24
|
+
constructor(filename:string,options?:{maxRecords?:number;leaseMs?:number;clock?:()=>number});
|
|
25
|
+
close():void;
|
|
26
|
+
putMapping(mapping:LetterMappingInput):LetterMapping;
|
|
27
|
+
mapping(id:string):LetterMapping|null;
|
|
28
|
+
same(mapping:LetterMapping):boolean;
|
|
29
|
+
claim(mapping:LetterMapping,deliveryId:string,eventId:string,payloadHash:string,observation:LetterObservation|null):LetterClaimResult;
|
|
30
|
+
committed(claim:LetterClaim):boolean;
|
|
31
|
+
commit(claim:LetterClaim,record:LetterEvidenceRecord):void;
|
|
32
|
+
fail(claim:LetterClaim,state:'unavailable'|'retryable'):boolean;
|
|
33
|
+
/** The host application must check its current customer authorization first. */
|
|
34
|
+
getRecord(customerId:string,collectionId:string,eventId:string):LetterStoredRecord|null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Node24-only concrete customer-side SQLite adapter. Not an OpenCrawl platform database. */
|
|
2
|
+
import {DatabaseSync} from 'node:sqlite';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
const id=value=>typeof value==='string'&&/^[A-Za-z0-9_-]{1,200}$/.test(value);
|
|
7
|
+
const bounded=(value,max)=>Number.isSafeInteger(value)&&value>0&&value<=max;
|
|
8
|
+
export class LetterEventStore{
|
|
9
|
+
constructor(filename,{maxRecords=10000,leaseMs=60000,clock=()=>Date.now()}={}){
|
|
10
|
+
if(Number(process.versions.node.split('.')[0])<24)throw new Error('This SQLite example requires Node24.');
|
|
11
|
+
if(!path.isAbsolute(filename)||!bounded(maxRecords,100000)||!bounded(leaseMs,300000))throw new Error('Use a bounded private absolute SQLite path.');
|
|
12
|
+
const parent=fs.lstatSync(path.dirname(filename));if(!parent.isDirectory()||parent.isSymbolicLink()||(parent.mode&0o077)!==0)throw new Error('Use a private directory.');
|
|
13
|
+
try{const fd=fs.openSync(filename,'wx',0o600);fs.closeSync(fd);}catch(error){if(error.code!=='EEXIST')throw error;}
|
|
14
|
+
const stat=fs.lstatSync(filename);if(!stat.isFile()||stat.isSymbolicLink()||(stat.mode&0o077)!==0)throw new Error('Use a private SQLite file.');
|
|
15
|
+
this.db=new DatabaseSync(filename);this.clock=clock;this.maxRecords=maxRecords;this.leaseMs=leaseMs;
|
|
16
|
+
this.db.exec(`PRAGMA foreign_keys=ON; PRAGMA busy_timeout=1000;
|
|
17
|
+
CREATE TABLE IF NOT EXISTS letter_mappings(id TEXT PRIMARY KEY,customer_id TEXT NOT NULL,tenant_id TEXT NOT NULL,collection_id TEXT NOT NULL,signing_secret_ref TEXT NOT NULL,revision INTEGER NOT NULL,active INTEGER NOT NULL);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS letter_events(customer_id TEXT NOT NULL,collection_id TEXT NOT NULL,event_id TEXT NOT NULL,mapping_id TEXT NOT NULL,revision INTEGER NOT NULL,payload_hash TEXT NOT NULL,state TEXT NOT NULL,claim TEXT,lease_until INTEGER,record TEXT,updated_at INTEGER NOT NULL,PRIMARY KEY(customer_id,collection_id,event_id));
|
|
19
|
+
CREATE TABLE IF NOT EXISTS letter_deliveries(mapping_id TEXT NOT NULL,delivery_id TEXT NOT NULL,customer_id TEXT NOT NULL,collection_id TEXT NOT NULL,event_id TEXT NOT NULL,payload_hash TEXT NOT NULL,PRIMARY KEY(mapping_id,delivery_id));`);
|
|
20
|
+
}
|
|
21
|
+
close(){this.db.close()}
|
|
22
|
+
transaction(fn){this.db.exec('BEGIN IMMEDIATE');try{const value=fn();this.db.exec('COMMIT');return value}catch(error){this.db.exec('ROLLBACK');throw error}}
|
|
23
|
+
/** Local administrative configuration only; never accept a webhook's mapping. */
|
|
24
|
+
putMapping(value){
|
|
25
|
+
for(const key of ['id','customerId','tenantId','collectionId','signingSecretRef'])if(!id(value[key]))throw new Error('Invalid server mapping.');
|
|
26
|
+
if(typeof value.active!=='boolean')throw new Error('Choose an explicit mapping state.');
|
|
27
|
+
return this.transaction(()=>{
|
|
28
|
+
const old=this.mapping(value.id);
|
|
29
|
+
if(old&&['customerId','tenantId','collectionId'].some(key=>old[key]!==value[key]))throw new Error('Mapping identity cannot be reassigned.');
|
|
30
|
+
const revision=(old?.revision??0)+1;
|
|
31
|
+
this.db.prepare('INSERT INTO letter_mappings VALUES(?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET signing_secret_ref=excluded.signing_secret_ref,revision=excluded.revision,active=excluded.active').run(value.id,value.customerId,value.tenantId,value.collectionId,value.signingSecretRef,revision,Number(value.active));
|
|
32
|
+
return this.mapping(value.id);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
mapping(identity){const row=this.db.prepare('SELECT * FROM letter_mappings WHERE id=?').get(identity);return row?{id:row.id,customerId:row.customer_id,tenantId:row.tenant_id,collectionId:row.collection_id,signingSecretRef:row.signing_secret_ref,revision:row.revision,active:Boolean(row.active)}:null}
|
|
36
|
+
same(mapping){const current=this.mapping(mapping.id);return Boolean(current?.active&&['customerId','tenantId','collectionId','signingSecretRef','revision'].every(key=>current[key]===mapping[key]))}
|
|
37
|
+
claim(mapping,deliveryId,eventId,payloadHash,observation){
|
|
38
|
+
if(!id(deliveryId)||!id(eventId)||!/^[a-f0-9]{64}$/.test(payloadHash))throw new Error('Invalid receipt identity.');
|
|
39
|
+
return this.transaction(()=>{
|
|
40
|
+
if(!this.same(mapping))throw new Error('Customer mapping changed.');
|
|
41
|
+
const args=[mapping.customerId,mapping.collectionId,eventId],existing=this.db.prepare('SELECT * FROM letter_events WHERE customer_id=? AND collection_id=? AND event_id=?').get(...args);
|
|
42
|
+
const delivery=this.db.prepare('SELECT * FROM letter_deliveries WHERE mapping_id=? AND delivery_id=?').get(mapping.id,deliveryId);
|
|
43
|
+
if(delivery&&(delivery.customer_id!==mapping.customerId||delivery.collection_id!==mapping.collectionId||delivery.event_id!==eventId||delivery.payload_hash!==payloadHash))throw new Error('Conflicting delivery identity.');
|
|
44
|
+
if(existing&&existing.payload_hash!==payloadHash)throw new Error('Conflicting event identity.');
|
|
45
|
+
if(!delivery){
|
|
46
|
+
if(this.db.prepare('SELECT COUNT(*) n FROM letter_deliveries').get().n>=this.maxRecords)throw new Error('Inbox capacity reached.');
|
|
47
|
+
this.db.prepare('INSERT INTO letter_deliveries VALUES(?,?,?,?,?,?)').run(mapping.id,deliveryId,...args,payloadHash);
|
|
48
|
+
}
|
|
49
|
+
if(existing&&['committed','unavailable'].includes(existing.state))return {state:'duplicate'};
|
|
50
|
+
const stamp=this.clock();
|
|
51
|
+
if(existing?.state==='processing'&&existing.lease_until>stamp)return {state:'busy'};
|
|
52
|
+
// Lease expiry only retries an idempotent retained read. Publication always
|
|
53
|
+
// requires the new claim; an earlier reader cannot commit after takeover.
|
|
54
|
+
if(!existing&&this.db.prepare('SELECT COUNT(*) n FROM letter_events').get().n>=this.maxRecords)throw new Error('Evidence capacity reached.');
|
|
55
|
+
const token=randomUUID();
|
|
56
|
+
this.db.prepare(`INSERT INTO letter_events VALUES(?,?,?,?,?,?,'processing',?,?,NULL,?)
|
|
57
|
+
ON CONFLICT(customer_id,collection_id,event_id) DO UPDATE SET mapping_id=excluded.mapping_id,revision=excluded.revision,state='processing',claim=excluded.claim,lease_until=excluded.lease_until,updated_at=excluded.updated_at`).run(...args,mapping.id,mapping.revision,payloadHash,token,stamp+this.leaseMs,stamp);
|
|
58
|
+
return {state:'claimed',mapping,deliveryId,eventId,payloadHash,token,observation};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
row(claim){return this.db.prepare('SELECT * FROM letter_events WHERE customer_id=? AND collection_id=? AND event_id=?').get(claim.mapping.customerId,claim.mapping.collectionId,claim.eventId)}
|
|
62
|
+
committed(claim){const row=this.row(claim);return row?.payload_hash===claim.payloadHash&&row.state==='committed'}
|
|
63
|
+
commit(claim,record){
|
|
64
|
+
const encoded=JSON.stringify(record);if(Buffer.byteLength(encoded)>262144)throw new Error('Evidence record exceeds the bound.');
|
|
65
|
+
return this.transaction(()=>{
|
|
66
|
+
const row=this.row(claim);if(row?.state==='committed'&&row.payload_hash===claim.payloadHash)return;
|
|
67
|
+
if(!this.same(claim.mapping)||row?.state!=='processing'||row.claim!==claim.token||row.lease_until<=this.clock())throw new Error('Publication claim is no longer current.');
|
|
68
|
+
this.db.prepare("UPDATE letter_events SET state='committed',record=?,claim=NULL,lease_until=NULL,updated_at=? WHERE customer_id=? AND collection_id=? AND event_id=?").run(encoded,this.clock(),claim.mapping.customerId,claim.mapping.collectionId,claim.eventId);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
fail(claim,state){
|
|
72
|
+
if(!['unavailable','retryable'].includes(state))throw new Error('Invalid failure state.');
|
|
73
|
+
return this.transaction(()=>{
|
|
74
|
+
const row=this.row(claim);
|
|
75
|
+
if(row?.state==='unavailable'&&row.payload_hash===claim.payloadHash)return true;
|
|
76
|
+
if(row?.state!=='processing'||row.claim!==claim.token)return false;
|
|
77
|
+
const next=this.same(claim.mapping)?state:'unavailable';
|
|
78
|
+
this.db.prepare('UPDATE letter_events SET state=?,record=NULL,claim=NULL,lease_until=NULL,updated_at=? WHERE customer_id=? AND collection_id=? AND event_id=?').run(next,this.clock(),claim.mapping.customerId,claim.mapping.collectionId,claim.eventId);
|
|
79
|
+
return next==='unavailable';
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Caller must enforce its application's current customer access before reading. */
|
|
83
|
+
getRecord(customerId,collectionId,eventId){const row=this.db.prepare('SELECT state,record,updated_at FROM letter_events WHERE customer_id=? AND collection_id=? AND event_id=?').get(customerId,collectionId,eventId);return row?{state:row.state,record:row.record?JSON.parse(row.record):null,updatedAt:row.updated_at}:null}
|
|
84
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {OpenCrawl,ChangeHunk,CollectionReceipt,ResearchJob,ResearchEvent,ResearchSearchResponse,SearchResult} from '@getopencrawl/sdk';
|
|
2
|
+
|
|
3
|
+
export type SeerPassage=Pick<SearchResult,'document_id'|'source_id'|'title'|'url'|'kind'|'metadata'|'source_created_at'|'source_updated_at'|'indexed_at'|'freshness'|'research_observation'>&{
|
|
4
|
+
citation:string;chunk_id?:string;content:string;
|
|
5
|
+
location:Record<string,unknown>&{start_char:number;end_char:number};
|
|
6
|
+
};
|
|
7
|
+
export type SeerPassagePack={
|
|
8
|
+
kind:'whole_returned_passages';source_text_is_untrusted:true;
|
|
9
|
+
budget_bytes:32768;utf8_bytes:number;returned_passage_count:number;
|
|
10
|
+
all_returned_passages_included:boolean;omitted_citations:string[];passages:SeerPassage[];
|
|
11
|
+
};
|
|
12
|
+
/** Fixed total serialized UTF-8 cap; completeness refers only to returned passages. */
|
|
13
|
+
export function packSeerPassages(evidence:ResearchSearchResponse):SeerPassagePack;
|
|
14
|
+
|
|
15
|
+
export type SeerPreparation = {
|
|
16
|
+
name:string;brief?:string;seedUrls:string[];allowedOrigins?:string[];
|
|
17
|
+
pageLimit?:number;scheduleMinutes?:number;includeMedia?:boolean;
|
|
18
|
+
idempotencyKey:string;signal?:AbortSignal;
|
|
19
|
+
};
|
|
20
|
+
export type SeerRunSelection = {collectionId:string;jobId:string;query:string;cursor?:string;includeVolatile?:boolean;signal?:AbortSignal};
|
|
21
|
+
export type SeerEvent = {
|
|
22
|
+
id:string;kind:ResearchEvent['kind'];url:string;observedAt:string;previousObservedAt:string|null;
|
|
23
|
+
before:ResearchEvent['before'];after:ResearchEvent['after'];changeSummary:ResearchEvent['change_summary'];gap:boolean;
|
|
24
|
+
changes:ChangeHunk[]|null;substance:'content'|'volatile_only'|null;
|
|
25
|
+
};
|
|
26
|
+
export type SeerRun = {
|
|
27
|
+
state:'processing'|'empty'|'review'|'needs_attention';job:ResearchJob;
|
|
28
|
+
events?:SeerEvent[];nextCursor?:string|null;evidence?:ResearchSearchResponse;interpretation?:string;
|
|
29
|
+
};
|
|
30
|
+
export function prepareSeer(client:OpenCrawl,input:SeerPreparation):Promise<CollectionReceipt>;
|
|
31
|
+
export function readSeerRun(client:OpenCrawl,input:SeerRunSelection):Promise<SeerRun>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Server-only integration boundary for Letter's Seer runner. No emails or webhooks are sent. */
|
|
2
|
+
import {OpenCrawlValidationError} from '@getopencrawl/sdk';
|
|
3
|
+
|
|
4
|
+
const PASSAGE_BUDGET=32_768;
|
|
5
|
+
const encoder=new TextEncoder();
|
|
6
|
+
const PASSAGE_FIELDS=['citation','document_id','chunk_id','source_id','title','url','kind','content','location','metadata','source_created_at','source_updated_at','indexed_at','freshness','research_observation'];
|
|
7
|
+
|
|
8
|
+
/** Package already-returned source passages, never another query or a summary.
|
|
9
|
+
* The fixed cap covers the complete serialized JSON, including provenance and
|
|
10
|
+
* omission metadata. Source strings remain untrusted data, not instructions.
|
|
11
|
+
*/
|
|
12
|
+
export function packSeerPassages(evidence){
|
|
13
|
+
const results=evidence?.results;
|
|
14
|
+
if(!Array.isArray(results)||results.length>50)throw new OpenCrawlValidationError('Pass an existing search response with at most 50 returned passages.');
|
|
15
|
+
const seen=new Set(),passages=[];
|
|
16
|
+
for(const result of results){
|
|
17
|
+
const {citation,content,document_id,location}=result??{};
|
|
18
|
+
if(typeof citation!=='string'||!/^S[1-9]\d{0,2}$/.test(citation)||seen.has(citation)||typeof document_id!=='string'||!document_id||typeof content!=='string'||!location||!Number.isSafeInteger(location.start_char)||!Number.isSafeInteger(location.end_char)||location.start_char<0||location.end_char-location.start_char!==Array.from(content).length)throw new OpenCrawlValidationError('Each passage needs a unique citation, document identity and exact codepoint source range.');
|
|
19
|
+
seen.add(citation);
|
|
20
|
+
// Select evidence/provenance fields only. Preview and compact-context offsets
|
|
21
|
+
// describe different slices and must never be relabeled as full-passage spans.
|
|
22
|
+
passages.push(Object.fromEntries(PASSAGE_FIELDS.filter(key=>Object.hasOwn(result,key)).map(key=>[key,result[key]])));
|
|
23
|
+
}
|
|
24
|
+
const build=(included,omitted)=>{
|
|
25
|
+
const value={kind:'whole_returned_passages',source_text_is_untrusted:true,budget_bytes:PASSAGE_BUDGET,utf8_bytes:0,returned_passage_count:passages.length,all_returned_passages_included:omitted.length===0,omitted_citations:omitted,passages:included};
|
|
26
|
+
let serialized;
|
|
27
|
+
for(let step=0;step<4;step++){serialized=JSON.stringify(value);const size=encoder.encode(serialized).length;if(size===value.utf8_bytes)return {value,serialized};value.utf8_bytes=size;}
|
|
28
|
+
throw new OpenCrawlValidationError('Could not account for the evidence envelope.');
|
|
29
|
+
};
|
|
30
|
+
let included=[],omitted=passages.map(p=>p.citation),packed=build(included,omitted);
|
|
31
|
+
for(const passage of passages){
|
|
32
|
+
const nextOmitted=omitted.filter(label=>label!==passage.citation),candidate=build([...included,passage],nextOmitted);
|
|
33
|
+
if(candidate.value.utf8_bytes<=PASSAGE_BUDGET){included=candidate.value.passages;omitted=nextOmitted;packed=candidate;}
|
|
34
|
+
}
|
|
35
|
+
// A detached JSON value prevents later mutation of the response from changing
|
|
36
|
+
// retained evidence; no source text is normalized, interpreted or executed.
|
|
37
|
+
return JSON.parse(packed.serialized);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Save the exact input/key before calling, then persist the accepted collection/job IDs.
|
|
41
|
+
* Pilot defaults are one page, manual checks and no media; opt into ongoing work explicitly.
|
|
42
|
+
*/
|
|
43
|
+
export async function prepareSeer(client,{name,brief,seedUrls,allowedOrigins,pageLimit=1,scheduleMinutes=0,includeMedia=false,idempotencyKey,signal}){
|
|
44
|
+
return client.collections.create({name,brief,seed_urls:seedUrls,allowed_origins:allowedOrigins,page_limit:pageLimit,schedule_minutes:scheduleMinutes,include_media:includeMedia},{idempotencyKey,signal});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resume the same accepted job after a process restart; never create a replacement blindly.
|
|
48
|
+
* By default, changes whose only differences are dates, clock times, relative
|
|
49
|
+
* ages or whitespace are omitted; they remain in the collection's history.
|
|
50
|
+
*/
|
|
51
|
+
export async function readSeerRun(client,{collectionId,jobId,query,cursor,includeVolatile=false,signal}){
|
|
52
|
+
const job=await client.jobs.get(jobId,{signal});
|
|
53
|
+
if(job.collection_id!==collectionId)throw new OpenCrawlValidationError('The job belongs to a different research collection.');
|
|
54
|
+
if(job.status==='queued'||job.status==='running')return {state:'processing',job};
|
|
55
|
+
// A terminal receipt is not proof of usable evidence. Do not turn an empty
|
|
56
|
+
// failed first run into an apparently ready search of unrelated older pages.
|
|
57
|
+
if(job.progress.indexed===0)return {state:['failed','cancelled'].includes(job.status)?'needs_attention':'empty',job};
|
|
58
|
+
const [events,evidence]=await Promise.all([
|
|
59
|
+
client.collections.events(collectionId,{limit:25,cursor,includeVolatile,signal}),
|
|
60
|
+
client.search({query,collection_ids:[collectionId],freshness:'indexed',limit:10,budget:6000},{signal}),
|
|
61
|
+
]);
|
|
62
|
+
return {
|
|
63
|
+
state:job.status==='completed'?'review':'needs_attention',
|
|
64
|
+
job,
|
|
65
|
+
events:events.items.map(event=>({
|
|
66
|
+
// Baseline is not a newly occurring business event. Unavailable is not a deletion.
|
|
67
|
+
id:event.id,kind:event.kind,url:event.url,observedAt:event.observed_at,
|
|
68
|
+
previousObservedAt:event.previous_observed_at,before:event.before,after:event.after,
|
|
69
|
+
changeSummary:event.change_summary,gap:event.gap,
|
|
70
|
+
// Removed/added lines for the host's positioning rule; absent on older observations.
|
|
71
|
+
changes:event.change_summary.changes??null,substance:event.change_summary.substance??null,
|
|
72
|
+
})),
|
|
73
|
+
nextCursor:events.next_cursor,
|
|
74
|
+
evidence,
|
|
75
|
+
// The host may apply a reviewed positioning rule, but must retain this evidence boundary.
|
|
76
|
+
interpretation:'Observed source changes; not independently verified purchase intent or business outcomes.',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type {OpenCrawl,Fetch,ServiceTenant,ServiceTenantReceipt,ServiceTenantOperation} from '@getopencrawl/sdk';
|
|
2
|
+
|
|
3
|
+
export type CustomerSecrets = {put:(tenantId:string,key:string)=>Promise<void>;get:(tenantId:string)=>Promise<string|null|undefined>};
|
|
4
|
+
export type CustomerReceiptWriter = (record:{tenant:ServiceTenant;receipt:ServiceTenantReceipt['receipt']})=>Promise<void>;
|
|
5
|
+
export type CustomerProvisionResult = {tenant:ServiceTenant;state:'key_stored'|'review_key_storage'};
|
|
6
|
+
export function provisionCustomer(parent:OpenCrawl,input:{customerId:string;name:string;createKey:string;
|
|
7
|
+
secrets:Pick<CustomerSecrets,'put'>;recordReceipt:CustomerReceiptWriter;signal?:AbortSignal}):Promise<CustomerProvisionResult>;
|
|
8
|
+
export function recoverProvisioning(parent:OpenCrawl,input:{operation:ServiceTenantOperation;idempotencyKey:string;
|
|
9
|
+
recordReceipt:CustomerReceiptWriter;signal?:AbortSignal}):Promise<{tenant:ServiceTenant;state:'revoked'|'review_key_storage'}>;
|
|
10
|
+
export function rotateCustomerKey(parent:OpenCrawl,input:{tenantId:string;expectedRevision:number;rotationKey:string;
|
|
11
|
+
secrets:Pick<CustomerSecrets,'put'>;recordReceipt:CustomerReceiptWriter;signal?:AbortSignal}):Promise<CustomerProvisionResult>;
|
|
12
|
+
export function clientForCustomer(input:{baseUrl:string;authorizedTenantId:string;secrets:Pick<CustomerSecrets,'get'>;fetch?:Fetch}):Promise<OpenCrawl>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Server-only: use the immutable SDK install command from Research → Use this search in your app.
|
|
2
|
+
* Without the UI, use packages.sdk.url from https://app.getopencrawl.com/downloads/manifest.json.
|
|
3
|
+
* Authorize the Letter customer before resolving its mapping; keep the parent key on your server.
|
|
4
|
+
* After provisioning, pass a separate child client to prepareSeer/readSeerRun in letter-seer.mjs.
|
|
5
|
+
* Create collections with that child client and retain their IDs; human-workspace collection IDs do not transfer.
|
|
6
|
+
* Keep the exact provisioning parent key: revoking/replacing it suspends its child credentials and jobs.
|
|
7
|
+
* Child-key rotation does not transfer this parent anchor.
|
|
8
|
+
* This adapter does not install itself into Letter or store credentials for the host product.
|
|
9
|
+
*/
|
|
10
|
+
import {OpenCrawl} from '@getopencrawl/sdk';
|
|
11
|
+
|
|
12
|
+
/** Persist the exact descriptor + operation key before calling; use a durable encrypted secret store. */
|
|
13
|
+
export async function provisionCustomer(parent,{customerId,name,createKey,secrets,recordReceipt,signal}){
|
|
14
|
+
return save(await parent.createServiceTenant({external_customer_id:customerId,name},{idempotencyKey:createKey,signal}),{secrets,recordReceipt});
|
|
15
|
+
}
|
|
16
|
+
export async function recoverProvisioning(parent,{operation,idempotencyKey,recordReceipt,signal}){
|
|
17
|
+
const result=await parent.getServiceTenantReceipt(operation,{idempotencyKey,signal});
|
|
18
|
+
await recordReceipt({tenant:result.tenant,receipt:result.receipt});
|
|
19
|
+
// No recoverable secret; check your store, then explicitly rotate if missing.
|
|
20
|
+
return {tenant:result.tenant,state:result.tenant.access_state==='revoked'?'revoked':'review_key_storage'};
|
|
21
|
+
}
|
|
22
|
+
export async function rotateCustomerKey(parent,{tenantId,expectedRevision,rotationKey,secrets,recordReceipt,signal}){
|
|
23
|
+
return save(await parent.rotateServiceTenantKey(tenantId,{expected_revision:expectedRevision},{idempotencyKey:rotationKey,signal}),{secrets,recordReceipt});
|
|
24
|
+
}
|
|
25
|
+
async function save(result,{secrets,recordReceipt}){
|
|
26
|
+
// Save the first-response secret first. A storage failure requires receipt recovery
|
|
27
|
+
// and an explicit rotation; never log this response or blindly repeat creation.
|
|
28
|
+
if(result.key!==null)await secrets.put(result.tenant.id,result.key);
|
|
29
|
+
await recordReceipt({tenant:result.tenant,receipt:result.receipt});
|
|
30
|
+
return {tenant:result.tenant,state:result.key!==null?'key_stored':'review_key_storage'};
|
|
31
|
+
}
|
|
32
|
+
export async function clientForCustomer({baseUrl,authorizedTenantId,secrets,fetch}){
|
|
33
|
+
const key=await secrets.get(authorizedTenantId);
|
|
34
|
+
if(!key)throw new Error('No stored customer credential. Recover its receipt and explicitly rotate if needed.');
|
|
35
|
+
return new OpenCrawl({baseUrl,apiKey:key,fetch,maxRetries:0}); // Explicit receipt recovery; never fall back to a parent key.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Offline, runnable contract demonstration. In-memory storage is for this mock only.
|
|
39
|
+
if(process.argv[1]?.endsWith('/letter-service-tenants.mjs')){
|
|
40
|
+
if(process.argv[2]!=='--mock')throw new Error('Run with --mock, or import these helpers into your server with durable storage.');
|
|
41
|
+
const id='stn_'+'1'.repeat(32),stamp='2026-01-01T00:00:00.000Z';
|
|
42
|
+
const tenant={id,external_customer_id:'customer-42',name:'Synthetic customer',workspace_id:'ws_synthetic',machine_user_id:'usr_synthetic',status:'active',access_state:'active',revision:1,provisioning_key_id:'key_parent',credential:null,created_at:stamp,updated_at:stamp,revoked_at:null};
|
|
43
|
+
const parent=new OpenCrawl({baseUrl:'https://example.invalid',apiKey:'synthetic-parent-key',fetch:async()=>new Response(JSON.stringify({tenant,receipt:{id:'streq_synthetic',operation:'create',tenant_id:id,target_revision:1,created_at:stamp},key:'synthetic-child-key',secret_available:true,replayed:false}))});
|
|
44
|
+
const vault=new Map(),records=[],secrets={put:async(id,key)=>{vault.set(id,key)},get:async(id)=>vault.get(id)};
|
|
45
|
+
const result=await provisionCustomer(parent,{customerId:'customer-42',name:'Synthetic customer',createKey:'synthetic-create-key-0001',secrets,recordReceipt:async value=>{records.push(value)}});
|
|
46
|
+
const child=await clientForCustomer({baseUrl:parent.baseUrl,authorizedTenantId:result.tenant.id,secrets,fetch:async(_url,init)=>{if(init.headers.Authorization!=='Bearer synthetic-child-key')throw new Error('Wrong customer credential');return new Response(JSON.stringify({items:[],next_cursor:null}))}});
|
|
47
|
+
const page=await child.collections.list();
|
|
48
|
+
console.log(JSON.stringify({mock:true,key_stored:result.state==='key_stored',metadata_receipts:records.length,child_collections:page.items.length,parent_credential_used_for_child:false}));
|
|
49
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Server-only Fetch API receiver. Mount this handler on one explicitly configured HTTPS path. */
|
|
2
|
+
import {verifyResearchWebhook} from '@getopencrawl/sdk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* processOnce must atomically deduplicate deliveryId and commit the event in your durable store.
|
|
6
|
+
* It resolves after that commit, including for a previously committed duplicate. Do not use
|
|
7
|
+
* a process-local Set: requests can be repeated after restarts or an unknown delivery outcome.
|
|
8
|
+
*/
|
|
9
|
+
export function createResearchWebhookReceiver({secret,collectionId,processOnce}){
|
|
10
|
+
if(typeof processOnce!=='function'||typeof collectionId!=='string'||!collectionId)throw new Error('A collection and durable processOnce handler are required.');
|
|
11
|
+
return async function receive(request){
|
|
12
|
+
if(request.method!=='POST')return new Response(null,{status:405,headers:{Allow:'POST'}});
|
|
13
|
+
if(!request.body)return new Response(null,{status:400});
|
|
14
|
+
if(Number(request.headers.get('Content-Length'))>16384)return new Response(null,{status:413});
|
|
15
|
+
const reader=request.body.getReader(),parts=[];let size=0,timer;
|
|
16
|
+
try{
|
|
17
|
+
const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>{void reader.cancel().catch(()=>{});reject(new Error('body timeout'));},10000)});
|
|
18
|
+
while(true){const part=await Promise.race([reader.read(),timeout]);if(part.done)break;size+=part.value.byteLength;if(size>16384){void reader.cancel().catch(()=>{});return new Response(null,{status:413})}parts.push(part.value)}
|
|
19
|
+
}catch{return new Response(null,{status:400})}finally{clearTimeout(timer);reader.releaseLock()}
|
|
20
|
+
const body=new Uint8Array(size);let offset=0;for(const part of parts){body.set(part,offset);offset+=part.byteLength}
|
|
21
|
+
let verified;
|
|
22
|
+
try{verified=await verifyResearchWebhook({secret,body,headers:{'webhook-id':request.headers.get('Webhook-Id'),'webhook-timestamp':request.headers.get('Webhook-Timestamp'),'webhook-signature':request.headers.get('Webhook-Signature')}})}catch{return new Response(null,{status:503})}
|
|
23
|
+
if(!verified)return new Response(null,{status:401});
|
|
24
|
+
let event;try{event=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(body))}catch{return new Response(null,{status:400})}
|
|
25
|
+
const kinds=['research.changed','research.recovered','research.baseline','research.unavailable','webhook.test'];
|
|
26
|
+
if(!event||typeof event!=='object'||Array.isArray(event)||typeof event.id!=='string'||event.id.length>200||!event.id||event.collection_id!==collectionId||!kinds.includes(event.type)||typeof event.created_at!=='string'||!event.data||typeof event.data!=='object'||Array.isArray(event.data))return new Response(null,{status:400});
|
|
27
|
+
try{await processOnce({deliveryId:verified.deliveryId,event});return new Response(null,{status:204})}catch{return new Response(null,{status:503})}
|
|
28
|
+
};
|
|
29
|
+
}
|