← Files nambliARCHIVED FILE
src/api-contract.mjs
16.8 KB · Nov 4, 2026 · 00:11 UTC
import {CALENDAR_LIMITS,SCHEMA_VERSION} from './platforms.mjs';
const ref=name=>({$ref:`#/components/schemas/${name}`}),str={type:'string'},nullable={type:['string','null']},uuid={type:'string',format:'uuid'},instant={type:'string',format:'date-time',description:'An exact datetime with Z or explicit UTC offset; no date-only values.'},obj={type:'object',additionalProperties:true};
const properties={id:uuid,title:{type:'string',minLength:1,maxLength:200},scheduledAt:instant,timeZone:{type:'string',description:'Explicit IANA timezone; legacy events may have null.'},customFields:obj,customFieldTypes:{type:'object',additionalProperties:{enum:['String','Number','Boolean','Null','JSON','File']}},publishingSettings:{anyOf:[ref('LinkedInSettings'),ref('XSettings'),ref('RedditSettings'),{type:'null'}]}};
const outcome={enum:['published','scheduled','blocked','failed','unknown']},followup={type:'object',additionalProperties:false,properties:{outcome:{enum:['not_requested','pending','completed','blocked','failed','unknown']}},required:['outcome']};
export const schemas={
Error:{type:'object',properties:{error:str,code:str},description:'Legacy routes use error; AI routes additionally use a stable uppercase code.'},
Identity:{type:'object',required:['accountId','email','authentication'],properties:{accountId:str,email:nullable,authentication:{enum:['session','bearer']},scope:{enum:['read','manage']}}},
EventCreate:{type:'object',additionalProperties:false,required:['title','scheduledAt'],properties},
EventPatch:{type:'object',additionalProperties:false,properties:{...properties,id:undefined,eventRevision:{type:'integer',minimum:1},fileFields:{type:'object',additionalProperties:uuid},deleteFileIds:{type:'array',maxItems:100,uniqueItems:true,items:uuid},publicationStatus:{const:'cancelled'},cancellationConfirmed:{const:true}},description:'Omitted fields are preserved; supplied customFields/types replace maps. Cancellation requires external confirmation and prior scheduled/unknown state. eventRevision provides caller compare-and-swap.'},
Event:{type:'object',properties:{...properties,timeZone:{type:['string','null']},createdAt:instant,updatedAt:instant,publicationStatus:{enum:['not_published','scheduled','published','cancelled','unknown']},executionMethod:{enum:['manual','platform_scheduler','automatic_runner']},basePostReadiness:{enum:['not_applicable','pending','ready','blocked','unknown']},publicationAutomated:{type:'boolean'},revision:{type:'integer',minimum:1},attachments:{type:'array',items:ref('Attachment')},publicationResult:obj,followUpResults:obj}},
EventList:{type:'object',properties:{events:{type:'array',items:{type:'object',properties:{id:uuid,title:str,scheduledAt:instant,customFields:obj,publicationStatus:str,executionMethod:str,basePostReadiness:str,revision:{type:'integer'},resultOutcome:nullable},description:'Compact projection only; no full post text or file bytes.'}},limit:{type:'integer'},offset:{type:'integer'},truncated:{type:'boolean'},nextOffset:{type:['integer','null']}}},
Attachment:{type:'object',properties:{id:uuid,eventId:uuid,filename:str,size:{type:'integer',maximum:CALENDAR_LIMITS.fileBytes},contentType:str,createdAt:instant,fieldName:nullable}},
LinkedInSettings:{type:'object',required:['platform','actor','destination','requestedFollowUps'],properties:{platform:{const:'linkedin'},actor:{type:'object',required:['kind','id','url'],properties:{kind:{enum:['personal','organization']},id:nullable,url:nullable}},destination:{type:'object',required:['kind','id','url'],properties:{kind:{enum:['public_feed','group']},id:nullable,url:nullable}},audience:{enum:['public','connections','group']},requestedFollowUps:{type:'object',required:['reaction','firstComment','featured'],properties:{reaction:{enum:['none','like','celebrate','support','love','insightful','funny']},firstComment:{type:['string','null'],maxLength:5000},featured:{type:'boolean'}}}},description:'Organization/group require exact ID. audience is required for new interactive plans and claims; omitted legacy audience is preserved as unknown. Featured cannot execute for organization/group. Personal/public adapter supports base post and Insightful; runtime/social authorization remain unknown.'},
XSettings:{type:'object',required:['platform','actorId','postType','destinationKind','replySettings','firstReply'],properties:{platform:{const:'x'},actorId:str,postType:{enum:['post','thread','reply']},destinationKind:{enum:['timeline','community']},communityId:str,replyToId:str,replySettings:{enum:['everyone','mentionedUsers','following','subscribers','verified']},firstReply:nullable},description:'Community destination requires communityId; reply requires replyToId. everyone represents omitted API reply_settings. Planning only; no X publisher is installed.'},
RedditSettings:{type:'object',required:['platform','actorId','subreddit','postType','postTitle','nsfw','spoiler','firstComment'],properties:{platform:{const:'reddit'},actorId:str,subreddit:{type:'string',pattern:'^[A-Za-z0-9_]{2,50}$'},postType:{enum:['self','link','image','video']},postTitle:str,flairId:nullable,nsfw:{type:'boolean'},spoiler:{type:'boolean'},firstComment:nullable},description:'Subreddit rules/flair requirements and media limits need current account/context validation. Planning only; no Reddit publisher is installed.'},
Result:{type:'object',additionalProperties:false,required:['eventRevision','idempotencyKey','outcome','executionMethod','occurredAt','externalId','externalUrl','evidence','followUps'],properties:{eventRevision:{type:'integer',minimum:1},idempotencyKey:uuid,outcome,executionMethod:{enum:['manual','platform_scheduler','automatic_runner']},readiness:{enum:['not_applicable','pending','ready','blocked','unknown']},occurredAt:instant,externalId:nullable,externalUrl:nullable,evidence:{type:'object',required:['kind'],properties:{kind:str,reference:str,details:obj}},followUps:{type:'object',additionalProperties:false,properties:{reaction:followup,firstComment:followup,featured:followup}}},description:'Same key/exact payload replay is idempotent. Published requires a confirmed external ID and recognized evidence reference. Native scheduling requires a confirmed queue ID; automatic runner scheduling requires a runner reference and confirmedEndToEnd evidence for ready. Unknown never authorizes retry. Independent followups cannot downgrade completed states. Receipt reporting never publishes.'},
Claim:{type:'object',additionalProperties:false,required:['eventRevision','runId','intentionalNow'],properties:{eventRevision:{type:'integer',minimum:1},runId:uuid,intentionalNow:{type:'boolean'}}},
Preferences:{type:'object',additionalProperties:false,properties:{timeZone:{type:['string','null']},platformDefaults:obj}},
DeviceStart:{type:'object',additionalProperties:false,required:['clientName','codeChallenge','scope'],properties:{clientName:{type:'string',minLength:1,maxLength:80},codeChallenge:{type:'string',pattern:'^[-_A-Za-z0-9]{43}$'},scope:{enum:['read','manage']}}},
DevicePoll:{type:'object',additionalProperties:false,required:['deviceCode','codeVerifier'],properties:{deviceCode:{type:'string',minLength:64,maxLength:64},codeVerifier:{type:'string',pattern:'^[-_A-Za-z0-9]{43,128}$'}}},
DeviceDecision:{type:'object',additionalProperties:false,required:['userCode'],properties:{userCode:{type:'string',minLength:10,maxLength:10}}},
TokenCreate:{type:'object',additionalProperties:false,required:['name'],properties:{name:{type:'string',minLength:1,maxLength:80}}}
};
const paths={};
function op(route,method,summary,{input,output=undefined,security,parameters=[],success=200,description='',contentType='application/json'}={}){
const errors={};for(const status of [400,401,403,404,409,411,413,429,500,503])errors[status]={description:status===409?'Conflict: account, revision, idempotency or publication guard':`HTTP ${status}; inspect stable code/error and do not blindly retry writes`,content:{'application/json':{schema:ref('Error')}}};
const params=[...route.matchAll(/\{([^}]+)\}/g)].map(m=>({name:m[1],in:'path',required:true,schema:m[1]==='platform'?{enum:['linkedin','x','reddit']}:uuid}));
(paths[route]??={})[method]={summary,description,operationId:`${method}_${route.replace(/[^a-z0-9]/gi,'_')}`,security:security??[{bearerAuth:[]},{cookieSession:[]}],parameters:[...params,...parameters],...(input?{requestBody:{required:true,content:{[contentType]:{schema:typeof input==='string'?ref(input):input}}}}:{}),responses:{[success]:{description:'Success',...(success===204?{}:{content:{'application/json':{schema:output?ref(output):obj}}})},...errors}};
}
const query=(name,schema,required=false)=>({name,in:'query',required,schema});
op('/api/health','get','Public health',{security:[]});op('/api/capabilities','get','Versioned enforced limits and connection metadata',{security:[],description:`Schema ${SCHEMA_VERSION}; no private data, no invented Cloudflare remaining quota.`});
op('/api/me','get','Verified server account identity',{output:'Identity'});
op('/api/account/preferences','get','Account-scoped explicit defaults',{output:'Preferences'});op('/api/account/preferences','patch','Save explicit account defaults',{input:'Preferences'});
op('/api/account/connection','delete','Revoke only the authenticating Bearer connection',{security:[{bearerAuth:[]}],success:204});
op('/api/account/tokens','get','Browser-only own connections/tokens',{security:[{cookieSession:[]}]});op('/api/account/tokens','post','Create hashed account token revealed once',{input:'TokenCreate',security:[{cookieSession:[]}],success:201});op('/api/account/tokens/{id}','delete','Browser-only revoke an own token',{security:[{cookieSession:[]}],success:204});
op('/api/ai/device/start','post','Start browser-approved one-time device pairing',{input:'DeviceStart',security:[],success:201,description:'No Cookie/Bearer accepted; 5 starts/IP per 10-minute window. Returns deviceCode privately to CLI, userCode, verificationUri, expiresIn=600, interval=5.'});
op('/api/ai/device/poll','post','Consume approved pairing with S256 verifier',{input:'DevicePoll',security:[],description:'No Cookie/Bearer accepted. Single-use; 5-second minimum interval. 428 AUTHORIZATION_PENDING, 429 SLOW_DOWN/RATE_LIMITED, 400 expired/invalid verifier, 403 denied. Token is returned only once to the CLI; never log it.'});paths['/api/ai/device/poll'].post.responses[428]={description:'AUTHORIZATION_PENDING',content:{'application/json':{schema:ref('Error')}}};
op('/api/ai/device','get','Review requesting device and signed-in account',{security:[{cookieSession:[]}],parameters:[query('code',{type:'string',minLength:10,maxLength:10},true)]});for(const action of ['approve','deny'])op(`/api/ai/device/${action}`,'post',`${action} this single request`,{input:'DeviceDecision',security:[{cookieSession:[]}],description:'Origin and X-Calendar-Account are mandatory. No redirect/callback or client ownership claim is accepted.'});
op('/api/calendar-entries','get','Page compact event summaries in half-open UTC range',{output:'EventList',parameters:[query('from',instant,true),query('to',instant,true),query('limit',{type:'integer',minimum:1,maximum:500,default:500}),query('offset',{type:'integer',minimum:0,maximum:100000,default:0})]});
op('/api/calendar-entries','post','Create event, with optional stable UUID',{input:'EventCreate',output:'Event',success:201,description:'Same supplied ID and exact data is idempotent (200); a different body conflicts. No publish/schedule side effect.'});
op('/api/calendar-entries/{id}','get','Read own event and attachment metadata',{output:'Event'});op('/api/calendar-entries/{id}','patch','Guarded event edit or confirmed cancellation',{input:'EventPatch',output:'Event'});op('/api/calendar-entries/{id}','delete','Soft-delete own event; queue owned file cleanup',{success:204});
op('/api/calendar-entries/{id}/files','post','Upload or replace an explicit File field',{input:{type:'object',required:['file'],properties:{file:{type:'string',format:'binary'},fieldName:{type:'string',maxLength:120},uploadId:uuid}},output:'Attachment',contentType:'multipart/form-data',success:201,description:'Content-Length required; server file limit 25 MiB plus at most 1 MiB multipart overhead. Same uploadId/bytes metadata is idempotent (200). Foreign IDs denied; failed replacement preserves prior file.'});
op('/api/files/{id}','get','Download authorized bytes',{description:'Content-Disposition attachment; nosniff; private no-store. KV propagation/quota may cause 503 with Retry-After. No secret-bearing public URLs.'});paths['/api/files/{id}'].get.responses[200]={description:'File bytes',content:{'application/octet-stream':{schema:{type:'string',format:'binary'}}}};
op('/api/files/{id}','delete','Delete owned file and queue KV cleanup',{success:204,description:'202 when cleanup remains pending.'});
op('/api/cleanup','post','Bounded owner-scoped tombstone cleanup',{description:'No active uploading reservation or referenced object is guessed safe to delete.'});
op('/api/publishing/platforms/{platform}','get','Shared platform-specific fields/capabilities',{description:'Cache by canonical origin/schema version and relevant account. Support, runtime availability, social authorization, content readiness and scheduling are distinct.'});
op('/api/calendar-entries/{id}/publishing-task','get','Read prepared task, fingerprint and owned file links',{description:'Exact copy/alt, actor/destination, requested independent followups and readiness reasons. Legacy task contract v1 remains compatible; timezone is nullable when unknown.'});
op('/api/calendar-entries/{id}/publication-claim','post','Atomically claim current revision before external submission',{input:'Claim',success:201,description:'One permanent account/event guard, stable dedupe key across revisions. Native queues, published/cancelled/unknown, incomplete/unsupported LinkedIn targets and unresolved URLs are blocked. Claims never publish. Acquired=false/conflict does not authorize a submit.'});
op('/api/calendar-entries/{id}/publication-result','post','Record external receipt and independent followups',{input:'Result',success:201,description:'Idempotent replay is 200. Requires current revision and atomic comparison of prior publication/followup state. An active claim accepts only evidence.kind=authorized_publisher, evidence.reference=claimId and evidence.details.dedupeKey from that claim. Unknown persists retryable=false/reconciliationRequired=true. Same external ID required for followup updates.'});
for(const [canonical,item]of Object.entries({...paths}))if(canonical.startsWith('/api/calendar-entries')){const alias=canonical.replace('/api/calendar-entries','/api/events');paths[alias]=JSON.parse(JSON.stringify(item));for(const method of Object.values(paths[alias])){method.operationId=method.operationId.replace('calendar_entries','events');method.description=`No-redirect compatibility alias. ${method.description}`;}}
op('/api/v1/auth/methods','get','Available identity providers',{security:[]});
op('/api/v1/auth/sign-in/social','post','Better Auth Google sign-in',{security:[],input:{type:'object',required:['provider'],properties:{provider:{const:'google'},callbackURL:{type:'string',description:'Same-origin relative path only.'}}},description:'Browser Origin required. Google prompt=select_account; identity scopes openid/email/profile, PKCE/state handled by Better Auth.'});
op('/api/v1/auth/sign-in/magic-link','post','Send one-time verified email sign-in',{security:[],input:{type:'object',required:['email'],properties:{email:{type:'string',format:'email'},callbackURL:str}},description:'Origin and same-origin relative return paths required. 15-minute single-use hashed link; database rate limits.'});
op('/api/v1/auth/callback/google','get','Better Auth OAuth callback',{security:[],description:'Better Auth verifies browser state/cookie/PKCE and verified email. Not a calendar-token redirect.'});
op('/api/v1/auth/magic-link/verify','get','Consume email sign-in link',{security:[],description:'Better Auth verifies single-use token; redirects to validated local path. Never disclose link tokens.'});op('/api/v1/auth/sign-out','post','Sign out the current browser session',{security:[{cookieSession:[]}],input:obj});
for(const route of ['/api/login','/api/logout']){op(route,'post','Retired shared-password route',{security:[],success:410});}
export const OPENAPI={openapi:'3.1.0',info:{title:'nambli',version:'1.1.0',description:'Account-scoped calendar API. JSON 256 KiB; files 25 MiB. Cookie mutations require exact Origin plus X-Calendar-Account; Bearer tokens are one account only, read or manage. Invalid explicit Bearer never falls back to cookies. Calendar writes never publish or schedule social content.'},servers:[{url:'https://nambli.com'}],paths,components:{securitySchemes:{bearerAuth:{type:'http',scheme:'bearer'},cookieSession:{type:'apiKey',in:'cookie',name:'__Secure-better-auth.session_token',description:'Managed by Better Auth; never extract it into an agent prompt.'}},schemas},'x-calendar-limits':CALENDAR_LIMITS,'x-schema-version':SCHEMA_VERSION};
SHA-256: 4c0e51a460d5428d232f915a93c0e164d2e981d239343b9d1cd2c034e554a2d9