@robono/server 0.21.0 → 0.22.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.
@@ -0,0 +1,111 @@
1
+ /** A single customer-facing component; account wiring and diagnostics stay generated. */
2
+ export function customerMessengerTemplates(options) {
3
+ const provider = options.auth ?? 'custom';
4
+ const web = options.platform === 'web';
5
+ const type = provider === 'supabase' ? 'SupabaseClient' : provider === 'firebase' ? 'Auth' : 'RobonoAccounts';
6
+ const typeImport = provider === 'supabase' ? "import type {SupabaseClient} from '@supabase/supabase-js';" : provider === 'firebase' ? "import type {Auth} from 'firebase/auth';" : "import type {RobonoAccounts} from './ConnectedRobonoChat';";
7
+ return {
8
+ 'client/CustomerMessenger.tsx': `'use client';
9
+ import React,{useMemo,type ComponentProps} from 'react';
10
+ import {ConnectedRobonoChat} from './ConnectedRobonoChat';
11
+ import {MessengerSetup} from './MessengerSetup';
12
+ ${typeImport}
13
+ ${provider === 'custom' ? '' : "import {robonoAccounts} from './robono-accounts';"}
14
+ type Props=Omit<ComponentProps<typeof ConnectedRobonoChat>,'accounts'>&{
15
+ ${provider === 'custom' ? 'accounts' : 'auth'}:${type};
16
+ /** Optional development-only connection check. Never shown in a production build. */
17
+ setupChecks?:boolean;
18
+ };
19
+ export function CustomerMessenger({${provider === 'custom' ? 'accounts' : 'auth'},setupChecks=false,...props}:Props){
20
+ ${provider === 'custom' ? '' : 'const accounts=useMemo(()=>robonoAccounts(auth),[auth]);'}
21
+ return <>
22
+ {setupChecks&&${web ? "process.env.NODE_ENV==='development'" : '__DEV__'}&&<MessengerSetup accounts={accounts}/>}
23
+ <ConnectedRobonoChat accounts={accounts} {...props}/>
24
+ </>;
25
+ }
26
+ `,
27
+ 'client/check-messenger-connection.ts': `export interface ConnectionCheck {name:string;status:'passed'|'failed'|'needs-action';detail:string}
28
+ /** Read-only checks in the signed-in app. Tokens never enter the report or Robono's website. */
29
+ export async function checkMessengerConnection(getAccessToken:()=>Promise<string>,signal:AbortSignal):Promise<ConnectionCheck[]>{
30
+ const endpoint=${JSON.stringify(web ? '/api/robono' : options.origin + '/api/robono')};
31
+ const checks:ConnectionCheck[]=[];
32
+ const run=async(name:string,operation:string,service:string,inspect:(data:any)=>Omit<ConnectionCheck,'name'>)=>{
33
+ try{
34
+ const token=await getAccessToken();
35
+ if(signal.aborted)throw Error('Cancelled');
36
+ const response=await fetch(endpoint+'?service='+service,{method:'POST',redirect:'error',signal,
37
+ headers:{'content-type':'application/json',authorization:'Bearer '+token},body:JSON.stringify({operation})});
38
+ if(!response.ok){
39
+ const detail=response.status===401?'Sign in again, then check that the server uses the same sign-in provider.':
40
+ response.status===403?'Check the server’s account permissions and allowed website address.':
41
+ response.status===404?'The /api/robono route is missing. Run the installer checklist.':
42
+ response.status===429?'The service is busy or a usage limit was reached. Try again shortly.':
43
+ 'The server could not complete this check. Check its private configuration and permission code.';
44
+ checks.push({name,status:'failed',detail});return;
45
+ }
46
+ checks.push({name,...inspect(await response.json())});
47
+ }catch{
48
+ if(signal.aborted)return;
49
+ checks.push({name,status:'failed',detail:'Could not connect with the current login. Check your server address and internet connection.'});
50
+ }
51
+ };
52
+ await run('Signed-in messaging','settings','messages',data=>({status:data.settings?'passed':'failed',detail:data.settings?'Your current account can load messaging settings.':'The route did not return messaging settings.'}));
53
+ if(!signal.aborted)await run('Conversations','conversations','messages',data=>({status:!Array.isArray(data.conversations)?'failed':data.conversations.length?'passed':'needs-action',detail:!Array.isArray(data.conversations)?'The route did not return conversations.':data.conversations.length?'Your account has available conversations.':'Connect a second test account and synchronize their permitted relationship from your server.'}));
54
+ if(!signal.aborted)await run('Notifications','notification-status','notifications',data=>({status:!Array.isArray(data.providers)?'failed':data.providers.length?'passed':'needs-action',detail:!Array.isArray(data.providers)?'Check that the server uses the current messaging package.':data.providers.length?'A notification provider is configured. Test an actual background notification next.':'Connect website or Apple/Google notification settings in Robono. Chat can work before alerts are connected.'}));
55
+ return checks;
56
+ }
57
+ `,
58
+ 'client/MessengerSetup.tsx': `'use client';
59
+ import React,{useEffect,useRef,useState} from 'react';
60
+ ${web ? '' : "import {View,Text,Pressable} from 'react-native';"}
61
+ import type {RobonoAccounts,RobonoAccount} from './ConnectedRobonoChat';
62
+ import {checkMessengerConnection,type ConnectionCheck} from './check-messenger-connection';
63
+ export function MessengerSetup({accounts}:{accounts:RobonoAccounts}){
64
+ const current=useRef<RobonoAccount|null>(null),generation=useRef(0),pending=useRef<AbortController|null>(null);
65
+ const [signedIn,setSignedIn]=useState(false),[busy,setBusy]=useState(false),[checks,setChecks]=useState<ConnectionCheck[]>([]);
66
+ useEffect(()=>{
67
+ generation.current++;current.current=null;pending.current?.abort();setBusy(false);setChecks([]);setSignedIn(false);
68
+ let live=true;
69
+ const off=accounts.subscribe(account=>{
70
+ if(!live)return;
71
+ if(current.current?.id!==account?.id){generation.current++;pending.current?.abort();setChecks([]);setBusy(false);}
72
+ current.current=account;setSignedIn(!!account);
73
+ });
74
+ return()=>{live=false;generation.current++;current.current=null;pending.current?.abort();off();};
75
+ },[accounts]);
76
+ const run=async()=>{
77
+ if(!current.current||pending.current&&!pending.current.signal.aborted)return;
78
+ const account=current.current,version=generation.current,controller=new AbortController();
79
+ pending.current=controller;setBusy(true);setChecks([]);
80
+ const timer=setTimeout(()=>controller.abort(),20000);
81
+ const token=async()=>{
82
+ if(current.current?.id!==account.id||generation.current!==version)throw Error('Account changed');
83
+ const value=await current.current.getAccessToken();
84
+ if(current.current?.id!==account.id||generation.current!==version)throw Error('Account changed');
85
+ return value;
86
+ };
87
+ try{
88
+ const result=await checkMessengerConnection(token,controller.signal);
89
+ if(generation.current===version)setChecks(controller.signal.aborted?[{name:'Connection',status:'failed',detail:'The check timed out. Check your connection and try again.'}]:result);
90
+ }finally{
91
+ clearTimeout(timer);
92
+ if(pending.current===controller)pending.current=null;
93
+ if(generation.current===version)setBusy(false);
94
+ }
95
+ };
96
+ ${web ? `return <aside aria-label="Messenger setup check" style={{padding:12,border:'1px solid #b8b8b8',borderRadius:8,background:'#fff',color:'#222',fontFamily:'system-ui',marginBottom:8}}>
97
+ <button type="button" disabled={!signedIn||busy} onClick={()=>void run()}>{busy?'Checking connection…':'Check connection'}</button>
98
+ <p style={{margin:'8px 0',fontSize:14}}>{signedIn?'Uses your current login. No copied tokens or messages.':'Sign in to your app to check the connection.'} Development only.</p>
99
+ {checks.length>0&&<ul aria-live="polite" style={{margin:0,paddingLeft:20}}>{checks.map(check=><li key={check.name}><strong>{check.status==='passed'?'Ready':check.status==='failed'?'Fix':'Next'} — {check.name}:</strong> {check.detail}</li>)}</ul>}
100
+ {checks.length>0&&<p style={{fontSize:12,marginBottom:0}}>These are connection checks. They do not prove account isolation, delivery or background notifications.</p>}
101
+ </aside>;` : `return <View accessibilityLabel="Messenger setup check" style={{padding:12,borderWidth:1,borderColor:'#b8b8b8',borderRadius:8,backgroundColor:'#fff',marginBottom:8}}>
102
+ <Pressable accessibilityRole="button" accessibilityState={{disabled:!signedIn||busy}} disabled={!signedIn||busy} onPress={()=>void run()} style={{padding:8}}><Text style={{color:'#174ea6',fontWeight:'600'}}>{busy?'Checking connection…':'Check connection'}</Text></Pressable>
103
+ <Text style={{color:'#222',marginVertical:8}}>{signedIn?'Uses your current login. No copied tokens or messages.':'Sign in to your app to check the connection.'} Development only.</Text>
104
+ {checks.map(check=><Text key={check.name} style={{color:'#222',marginBottom:6}}>{check.status==='passed'?'Ready':check.status==='failed'?'Fix':'Next'} — {check.name}: {check.detail}</Text>)}
105
+ {checks.length>0&&<Text style={{color:'#555',fontSize:12}}>These are connection checks. They do not prove account isolation, delivery or background notifications.</Text>}
106
+ </View>;`}
107
+ }
108
+ `,
109
+ };
110
+ }
111
+ //# sourceMappingURL=messenger-customer-templates.js.map
@@ -0,0 +1,121 @@
1
+ import { connectStarterTemplates } from './messenger-connected-templates.js';
2
+ import { mkdir, writeFile, access, realpath, lstat, rm } from 'node:fs/promises';
3
+ import { resolve, dirname, relative, isAbsolute, join } from 'node:path';
4
+ /** Pure templates contain no credentials. Host authentication deliberately fails closed. */
5
+ export function messengerStarterFiles(options) {
6
+ const origin = new URL(options.origin);
7
+ if (origin.origin !== options.origin || origin.username || origin.password || !(origin.protocol === 'https:' || origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))
8
+ throw Error('Use your exact HTTPS website origin, or localhost for development.');
9
+ if (!['web', 'expo'].includes(options.platform) || !['next', 'fetch'].includes(options.framework) || !options.companyName.trim() || options.companyName.length > 100)
10
+ throw Error('Choose Website or React Native, your server framework, and a company name.');
11
+ if (options.platform === 'expo' && origin.protocol !== 'https:')
12
+ throw Error('Phone apps need an HTTPS server address reachable from the phone.');
13
+ const q = JSON.stringify, route = options.framework === 'next' ? 'app/api/robono/route.ts' : 'server/robono-route.ts';
14
+ const auth = `import type {MessengerAccess} from '@robono/server';
15
+ // Replace ONLY these two functions with your existing account/permission service.
16
+ // Do not read a user ID from the request body. Verify expiry and suspension.
17
+ export async function authenticate(request:Request):Promise<string|null>{
18
+ throw Error('Connect your existing session verification in robono-access.ts');
19
+ }
20
+ // Account operations such as settings, conversations and notifications.register-device
21
+ // have no conversation ID. Allow them only for active, eligible accounts.
22
+ // Chat operations also need current membership and write/read permission checks.
23
+ export async function authorize(access:MessengerAccess):Promise<boolean>{
24
+ throw Error('Connect your existing messaging permission check in robono-access.ts');
25
+ }
26
+ `;
27
+ const files = {
28
+ '.gitignore': '.env\n.env.local\nnode_modules/\n',
29
+ '.env.example': `# Server only. Never prefix this with NEXT_PUBLIC_, VITE_ or EXPO_PUBLIC_.\nROBONO_API_KEY=\n# Optional comma-separated exact website origins. Set this before deployment.\nROBONO_ALLOWED_ORIGINS=${options.origin}\n`,
30
+ 'server/robono-access.ts': auth,
31
+ 'server/robono.ts': `import {createRobonoMessengerServer} from '@robono/server';
32
+ import {authenticate,authorize} from './robono-access';
33
+ let messenger:ReturnType<typeof createRobonoMessengerServer>|undefined;
34
+ export function getMessenger(){
35
+ if(messenger)return messenger;
36
+ const apiKey=process.env.ROBONO_API_KEY;
37
+ if(!apiKey)throw Error('Set ROBONO_API_KEY on your server.');
38
+ const allowedOrigins=process.env.ROBONO_ALLOWED_ORIGINS?.split(',').map((value:string)=>value.trim()).filter(Boolean)??[${q(options.origin)}];
39
+ return messenger=createRobonoMessengerServer({apiKey,authenticate,authorize,allowedOrigins});
40
+ }
41
+ `,
42
+ [route]: `import {getMessenger} from ${q(options.framework === 'next' ? '../../../server/robono' : './robono')};
43
+ ${options.framework === 'next' ? "export const runtime = 'nodejs';\n" : ''}export const GET=(request:Request)=>getMessenger().handle(request);
44
+ export const POST=(request:Request)=>getMessenger().handle(request);
45
+ `,
46
+ 'server/synchronize-contacts.ts': `// Run on your server when your own relationship record changes.
47
+ // Pass the server-only messenger instance from your application. Revisions come from YOUR database.
48
+ export async function synchronizeFriendship(messenger:import('@robono/server').RobonoMessengerServer,friendship:{userA:string;userB:string;canMessage:boolean;revision:number}){
49
+ return messenger.robono.setRelationship({externalUserIds:[friendship.userA,friendship.userB],allowed:friendship.canMessage,revision:friendship.revision});
50
+ }
51
+ `,
52
+ 'ROBONO-SETUP.md': `# Connect ${options.companyName.replace(/[\r\n]/g, ' ')} to Robono\n\nThese are prepared integration files, not a replacement for your app. No existing files were changed.\n\n1. Install the packages listed below in the corresponding server and client projects.\n2. Copy the generated files into your app. Put the Robono key only in the server's environment.\n3. Connect the two functions in robono-access.ts to your existing sign-in and permission checks. They reject access until connected.\n4. When your app permits or removes a friendship, call synchronizeFriendship with its current revision. Supply current contact names/avatars to the client. Group memberships use messenger.robono.setGroup.\n5. Mount GET and POST at /api/robono. Add the client screen, pass your signed-in account and token-refresh function, and call its signOut() before ending a session.\n6. Run robono-messenger-check against your own route with a disposable test-user token. Then exchange a message between two authorized users.\n\nServer: npm install @robono/server\nTypeScript server: npm install --save-dev typescript @types/node\nClient: npm install @robono/messaging @robono/${options.platform === 'web' ? 'messaging-web react react-dom' : 'messaging-react-native'}\n\n${options.platform === 'expo' ? 'For React Native, follow the native dependency and Expo configuration instructions in the package README before rebuilding iPhone and Android. Your app needs its own Apple/Google notification credentials; upload private keys only in Robono’s notification setup.' : 'For website alerts, enter your HTTPS origin and chat page in Robono’s notification setup. The supplied messenger handles the browser helper. No Apple/Google private key is needed.'}\n\nKeep feature choices in Robono’s Features & preview. Do not duplicate them in frontend permission switches. Your server remains responsible for who may use the service. Never return true for every permission request.\n\n## Local connection check\nSet ROBONO_MESSENGER_URL, ROBONO_TEST_USER_TOKEN and ROBONO_WEBSITE_ORIGIN in your local environment. Then run:\n\n\`\`\`sh\nnpx --package @robono/server robono-messenger-check\n# Shareable diagnostic output; contains no token or message content:\nnpx --package @robono/server robono-messenger-check --json\n\`\`\`\n\n## Assistance\nAn AI coding assistant may use these files and package documentation to connect your existing account service. It must preserve authentication, expiry, suspension, current memberships and server-only keys. It must not replace them with a demo password or an always-allow function. Do not send credentials or customer messages to support chat.\n`,
53
+ };
54
+ if (options.platform === 'web')
55
+ files['client/RobonoChat.tsx'] = `'use client';
56
+ import {useEffect,useRef} from 'react';
57
+ import {mountRobonoMessenger} from '@robono/messaging-web';
58
+ type Options=Parameters<typeof mountRobonoMessenger>[1];
59
+ // Keep getAccessToken and contacts stable. The token callback must return a current token.
60
+ export function RobonoChat({userId,getAccessToken,contacts,onReady}:{userId:string;getAccessToken:Options['getAccessToken'];contacts:Options['contacts'];onReady:(chat:ReturnType<typeof mountRobonoMessenger>|null)=>void}){
61
+ const element=useRef<HTMLDivElement>(null);
62
+ useEffect(()=>{
63
+ if(!element.current)return;
64
+ const chat=mountRobonoMessenger(element.current,{endpoint:'/api/robono',companyName:${q(options.companyName)},userId,getAccessToken,contacts});
65
+ onReady(chat);return()=>{onReady(null);chat.unmount();};
66
+ },[userId,getAccessToken,contacts,onReady]);
67
+ return <div ref={element} style={{height:'100%',minHeight:400}}/>;
68
+ }
69
+ // Store onReady's chat handle. Await chat.signOut() BEFORE ending your own session.
70
+ `;
71
+ else
72
+ files['client/RobonoChat.tsx'] = `import React,{useEffect,useState} from 'react';
73
+ import {createExpoMessenger,ExpoMessenger,type ExpoMessengerProps} from '@robono/messaging-react-native/expo';
74
+ type Connection=ReturnType<typeof createExpoMessenger>;
75
+ export function RobonoChat({userId,getAccessToken,contacts,isCallActive,onReady}:{userId:string;getAccessToken:()=>Promise<string>;contacts:ExpoMessengerProps['contacts'];isCallActive:()=>boolean;onReady:(connection:Connection|null)=>void}){
76
+ const [active,setActive]=useState<{userId:string;connection:Connection}|null>(null);
77
+ useEffect(()=>{
78
+ const connection=createExpoMessenger({endpoint:${q(options.origin + '/api/robono')},userId,getAccessToken,isCallActive});
79
+ setActive({userId,connection});onReady(connection);
80
+ return()=>{onReady(null);connection.dispose();};
81
+ },[userId,getAccessToken,isCallActive,onReady]);
82
+ if(!active||active.userId!==userId)return null;
83
+ return <ExpoMessenger {...active.connection} userId={userId} companyName={${q(options.companyName)}} contacts={contacts}/>;
84
+ }
85
+ // Mount inside your app's safe area. Keep callbacks stable. Await connection.signOut()
86
+ // BEFORE ending your session; dispose only detaches the screen.
87
+ `;
88
+ return connectStarterTemplates(files, options);
89
+ }
90
+ /** All-or-nothing new directory; never edits a customer's existing app or env file. */
91
+ export async function writeMessengerStarter(destination, options) {
92
+ const files = messengerStarterFiles(options), target = resolve(destination), parent = await realpath(dirname(target));
93
+ if (join(parent, target.slice(dirname(target).length + 1)) !== target)
94
+ throw Error('Use a destination without symbolic-link parents.');
95
+ try {
96
+ await access(target);
97
+ throw Error('That directory already exists. Choose a new directory.');
98
+ }
99
+ catch (e) {
100
+ if (e.code !== 'ENOENT')
101
+ throw e;
102
+ }
103
+ await mkdir(target, { mode: 0o700 });
104
+ try {
105
+ for (const [name, content] of Object.entries(files)) {
106
+ const path = resolve(target, name), local = relative(target, path);
107
+ if (local.startsWith('..') || isAbsolute(local))
108
+ throw Error('Invalid template path');
109
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
110
+ if ((await lstat(dirname(path))).isSymbolicLink())
111
+ throw Error('Unexpected symbolic link');
112
+ await writeFile(path, content, { flag: 'wx', mode: 0o600 });
113
+ }
114
+ }
115
+ catch (e) {
116
+ await rm(target, { recursive: true, force: true });
117
+ throw e;
118
+ }
119
+ return Object.keys(files);
120
+ }
121
+ //# sourceMappingURL=messenger-init.js.map
@@ -0,0 +1,243 @@
1
+ import { messengerPreviewTemplates } from './messenger-preview-template.js';
2
+ import { readFile, writeFile, mkdir, lstat, realpath, unlink } from 'node:fs/promises';
3
+ import { resolve, join, dirname, relative } from 'node:path';
4
+ import spawn from 'cross-spawn';
5
+ import { messengerStarterFiles } from './messenger-init.js';
6
+ async function exists(file) { try {
7
+ await lstat(file);
8
+ return true;
9
+ }
10
+ catch (e) {
11
+ if (e.code === 'ENOENT')
12
+ return false;
13
+ throw e;
14
+ } }
15
+ async function project(directory) {
16
+ const root = resolve(directory);
17
+ if (await realpath(root) !== root)
18
+ throw Error('Use a project path without symbolic links.');
19
+ const stat = await lstat(join(root, 'package.json'));
20
+ if (!stat.isFile() || stat.isSymbolicLink())
21
+ throw Error('Use a regular package.json.');
22
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
23
+ return { root, pkg, deps: { ...pkg.devDependencies, ...pkg.dependencies } };
24
+ }
25
+ async function safePath(file) {
26
+ let part = resolve(file);
27
+ while (true) {
28
+ if (await exists(part)) {
29
+ const s = await lstat(part);
30
+ if (s.isSymbolicLink())
31
+ throw Error('Installation paths cannot contain symbolic links.');
32
+ }
33
+ const parent = dirname(part);
34
+ if (parent === part)
35
+ break;
36
+ part = parent;
37
+ }
38
+ }
39
+ async function appRoot(root) {
40
+ for (const base of ['src', ''])
41
+ if (await exists(join(root, base, 'app'))) {
42
+ await safePath(join(root, base, 'app'));
43
+ return base;
44
+ }
45
+ throw Error('Automatic website installation supports Next.js App Router. Create its app directory first, or use robono-messenger-init for another server.');
46
+ }
47
+ function installCommand(root, manager, packages) {
48
+ if (!['npm', 'pnpm', 'yarn'].includes(manager))
49
+ throw Error('Supported package managers: npm, pnpm, yarn.');
50
+ return { cwd: root, command: manager, args: manager === 'npm' ? ['install', '--ignore-scripts', '--', ...packages] : manager === 'pnpm' ? ['add', '--ignore-scripts', '--', ...packages] : ['add', '--ignore-scripts', ...packages] };
51
+ }
52
+ async function manager(root, pkg) {
53
+ const locks = [];
54
+ for (const [file, name] of [['package-lock.json', 'npm'], ['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn']])
55
+ if (await exists(join(root, file)))
56
+ locks.push(name);
57
+ const named = pkg.packageManager?.split('@')[0];
58
+ if (locks.length > 1 || named && locks.length && locks[0] !== named)
59
+ throw Error('Conflicting package-manager files. Keep the lockfile used by this project before installing.');
60
+ const result = named ?? locks[0] ?? 'npm';
61
+ // Yarn Berry has different script controls; do not silently run hooks or rewrite its configuration.
62
+ if (result === 'yarn' && pkg.packageManager && !pkg.packageManager.startsWith('yarn@1.'))
63
+ throw Error('Use the generated-file command for Yarn 2+; automatic installation currently supports Yarn 1, npm and pnpm.');
64
+ return result;
65
+ }
66
+ export async function planMessengerInstallation(options) {
67
+ return createInstallationPlan(options, false);
68
+ }
69
+ async function createInstallationPlan(options, resume) {
70
+ if (Number(process.versions.node.split('.')[0]) < 22)
71
+ throw Error('Use Node.js 22 or later for automatic installation.');
72
+ const client = await project(options.project), platform = client.deps.expo ? 'expo' : client.deps.next ? 'web' : null;
73
+ if (!platform)
74
+ throw Error('Automatic installation supports Next.js App Router and Expo. For other projects use robono-messenger-init.');
75
+ if (platform === 'expo' && !/^[~^]?(55|56)\./.test(client.deps.expo))
76
+ throw Error('The supplied Expo messenger supports Expo 55–56. Keep your current app unchanged and check the mobile installation guide.');
77
+ if (platform === 'expo' && !options.serverProject)
78
+ throw Error('Pass --server-project with your existing Next.js server folder. A phone app must not contain the Robono server key.');
79
+ const server = platform === 'web' ? client : await project(options.serverProject);
80
+ if (!server.deps.next)
81
+ throw Error('The automatic server connection needs Next.js App Router. Use robono-messenger-init for other servers.');
82
+ if (platform === 'expo' && server.root === client.root)
83
+ throw Error('Use a separate server project; never install server credentials inside the phone app.');
84
+ const serverBase = await appRoot(server.root), clientBase = platform === 'web' ? serverBase : (await exists(join(client.root, 'src')) ? 'src' : '');
85
+ const detected = client.deps['@supabase/supabase-js'] ? 'supabase' : client.deps.firebase ? 'firebase' : 'custom';
86
+ if (!options.auth && client.deps['@supabase/supabase-js'] && client.deps.firebase)
87
+ throw Error('Both sign-in providers are installed. Choose --auth supabase, firebase or custom.');
88
+ const auth = options.auth ?? detected;
89
+ const providerName = auth === 'supabase' ? '@supabase/supabase-js' : auth === 'firebase' ? 'firebase' : null;
90
+ if (providerName && client.deps[providerName]) {
91
+ const installed = await readFile(join(client.root, 'node_modules', providerName, 'package.json'), 'utf8').then(value => JSON.parse(value).version).catch(() => client.deps[providerName]);
92
+ const major = Number(installed.match(/^[~^]?(\d+)/)?.[1]);
93
+ if (!major || major < (auth === 'supabase' ? 2 : 9))
94
+ throw Error('This sign-in package needs a newer API. Keep your current sign-in unchanged and use --auth custom, or update it separately.');
95
+ }
96
+ const templates = messengerStarterFiles({ platform, framework: 'next', origin: options.origin, companyName: options.companyName, auth });
97
+ const files = [], replacements = [];
98
+ for (const [name, content] of Object.entries(templates)) {
99
+ if (name.startsWith('client/'))
100
+ files.push({ path: join(client.root, clientBase, 'robono', name), content });
101
+ else if (name.startsWith('server/'))
102
+ files.push({ path: join(server.root, serverBase, 'robono', name), content });
103
+ }
104
+ files.push({ path: join(server.root, serverBase, 'app/api/robono/route.ts'), content: templates['app/api/robono/route.ts'].replace('../../../server/robono', '../../../robono/server/robono') });
105
+ const preview = messengerPreviewTemplates();
106
+ files.push({ path: join(server.root, serverBase, 'app/robono-preview/page.tsx'), content: preview.page }, { path: join(server.root, serverBase, 'robono/client/RobonoPreview.tsx'), content: preview.client });
107
+ const notes = [`Detected ${platform === 'web' ? 'Next.js website' : 'Expo phone app'} and ${auth} sign-in.`,
108
+ 'Start your Next.js development server and open /robono-preview for the two-person local example. It returns 404 in production.',
109
+ 'Connect your current messaging permission check in robono/server/robono-access.ts. Access stays blocked until then.',
110
+ 'Keep provider credentials and the Robono key on the server. The installer never reads or edits your .env files.',
111
+ 'Mount CustomerMessenger with your existing sign-in client and contacts. Account wiring is included.',
112
+ 'Connect website alerts or Apple/Google credentials in Robono → Your app → Message notifications. Chat works before alerts are configured.'];
113
+ const setup = { schema: 2, platform, auth, companyName: options.companyName, origin: options.origin, serverProject: relative(client.root, server.root) || '.', dependencies: 'pending', checks: { files: 'installed', permissions: 'needs-connection', serverKey: 'needs-server-environment', contacts: 'needs-connection', notifications: 'needs-configuration', liveConnection: 'not-tested' } };
114
+ files.push({ path: join(client.root, '.robono/setup.json'), content: JSON.stringify(setup, null, 2) + '\n' });
115
+ files.push({ path: join(client.root, '.robono/SETUP.md'), content: '# Connect your messenger\n\n' + notes.map(x => '- ' + x).join('\n') + '\n\n## Connect your screen\n' + (auth === 'custom' ? 'Implement RobonoAccounts from client/ConnectedRobonoChat.tsx using your existing sign-in. Render <CustomerMessenger accounts={yourAccounts} contacts={yourContacts}/>.' : 'Import CustomerMessenger from the generated client/CustomerMessenger.tsx. Render <CustomerMessenger auth={yourExistingSignInClient} contacts={yourContacts}/>.') + ' Give the messenger an area with a height. Keep it mounted in your signed-in application. It follows account changes; names and avatars update without restarting chat. For Supabase use its client; for Firebase use its Auth instance.\n\n## Allow the right people\nConnect authorize in robono-access.ts to current account eligibility, suspension and conversation permissions. It denies access until implemented. When a friendship changes, call synchronizeFriendship(getMessenger(), friendship) on your server. Use revisions from your database; group membership uses getMessenger().robono.setGroup. Never use an always-allow function.\n\n## Sign-out\nThe messenger account menu clears local content and unregisters notifications before provider sign-out. If your own account menu signs out first, provider events still clear the visible chat. Keep account suspension and relationship revocation synchronized with Robono; an expired session cannot reliably unregister a remote device. Never substitute another account’s token.\n\n## Check the connection\nDuring development, add setupChecks to CustomerMessenger and press Check connection after signing in to your app. It uses your current login; no copied tokens are needed. It checks settings, available chats and notification configuration without sending a message. This optional panel is hidden in production.\n\nRun robono-messenger-install --check for the local checklist. For additional command-line security checks, set ROBONO_MESSENGER_URL, ROBONO_WEBSITE_ORIGIN and ROBONO_TEST_USER_TOKEN locally. Add --json for a shareable report without secrets. No local checker can prove your complete permission rules; test denied accounts and cross-account access too.\n\n## Interrupted installation\nRun robono-messenger-install --resume from the same project. It reinstalls the required packages without replacing your integration code, screens or environment files.\n\n## Server configuration\nSet ROBONO_API_KEY privately on the server. Use ROBONO_ALLOWED_ORIGINS for your production website addresses instead of editing generated code. Never use a wildcard. Existing Next.js Supabase public settings are reused; see server.env.example.\n\n## Notifications\nFor website alerts configure your HTTPS origin and chat path. For iPhone, upload your APNs .p8 key, key ID, Team ID and Bundle ID in Robono. For Android, configure google-services.json in your app and upload the Firebase messaging service account privately in Robono. Never put private keys in client code. Build both apps after native dependency changes.\n\n## Local preview\nOpen /robono-preview on your Next.js development server. It uses the actual web messenger with fictional Alice and Bob and local text only. It sends no customer requests and returns 404 in production.\n' });
116
+ files.push({ path: join(server.root, '.robono/server.env.example'), content: templates['.env.example'] });
117
+ const serverPackages = ['@robono/server@^0.21.0', ...(platform === 'expo' ? ['@robono/messaging@^0.4.1', '@robono/messaging-web@^0.6.0'] : []), ...(auth === 'supabase' ? ['@supabase/supabase-js@^2'] : auth === 'firebase' ? ['firebase-admin@^14'] : [])];
118
+ const clientPackages = ['@robono/messaging@^0.4.1', `@robono/messaging-${platform === 'web' ? 'web' : 'react-native'}@^0.6.0`, ...(auth === 'supabase' ? ['@supabase/supabase-js@^2'] : auth === 'firebase' ? ['firebase@^13'] : [])];
119
+ const packageName = (spec) => spec.slice(0, spec.lastIndexOf('@'));
120
+ const keepExistingProvider = (spec, deps) => !['firebase', 'firebase-admin', '@supabase/supabase-js'].includes(packageName(spec)) || !deps[packageName(spec)];
121
+ const serverInstall = serverPackages.filter(spec => keepExistingProvider(spec, server.deps)), clientInstall = clientPackages.filter(spec => keepExistingProvider(spec, client.deps));
122
+ const commands = [];
123
+ if (platform === 'web')
124
+ commands.push(installCommand(client.root, await manager(client.root, client.pkg), [...new Set([...serverInstall, ...clientInstall])]));
125
+ else {
126
+ commands.push(installCommand(server.root, await manager(server.root, server.pkg), serverInstall));
127
+ const cm = await manager(client.root, client.pkg);
128
+ commands.push(installCommand(client.root, cm, clientInstall));
129
+ // Expo selects versions compatible with the customer's installed SDK, not arbitrary latest peers.
130
+ commands.push({ cwd: client.root, command: process.execPath, args: [join(client.root, 'node_modules/expo/bin/cli'), 'install', 'expo-audio', 'expo-image-picker', 'expo-document-picker', 'expo-file-system', 'expo-sharing', 'expo-video', 'expo-crypto', 'react-native-safe-area-context', 'react-native-svg', 'expo-clipboard', 'expo-image-manipulator', 'expo-video-thumbnails', 'react-native-webview', 'expo-blur', 'expo-notifications', 'expo-secure-store', '--' + cm] });
131
+ if (await exists(join(client.root, 'app.config.js')) || await exists(join(client.root, 'app.config.ts')))
132
+ throw Error('Your Expo configuration is code. Use robono-messenger-init and the mobile guide so your existing configuration is preserved.');
133
+ const configPath = join(client.root, 'app.json');
134
+ await safePath(configPath);
135
+ const before = await readFile(configPath, 'utf8'), config = JSON.parse(before);
136
+ if (!config.expo || typeof config.expo !== 'object' || Array.isArray(config.expo))
137
+ throw Error('Expected an expo configuration object in app.json.');
138
+ const plugins = config.expo?.plugins ?? [];
139
+ if (!Array.isArray(plugins))
140
+ throw Error('Expected an Expo plugins array in app.json.');
141
+ const names = plugins.map(p => Array.isArray(p) ? p[0] : p);
142
+ const required = [['expo-audio', { microphonePermission: 'Allow microphone access to record voice messages.', enableBackgroundRecording: false }], ['expo-image-picker', { cameraPermission: 'Allow camera access to take photos and videos for messages.' }], 'expo-notifications', '@robono/messaging-react-native'];
143
+ const added = required.filter(p => !names.includes(Array.isArray(p) ? p[0] : p));
144
+ if (added.length) {
145
+ config.expo.plugins = [...plugins, ...added];
146
+ replacements.push({ path: configPath, before, after: JSON.stringify(config, null, 2) + '\n' });
147
+ files.push({ path: join(client.root, '.robono/app.json.before-install'), content: before });
148
+ }
149
+ notes.push('Added missing Expo plugins; existing permission wording is preserved. Rebuild both iPhone and Android after native dependency changes.');
150
+ }
151
+ if (!resume)
152
+ for (const file of files) {
153
+ await safePath(file.path);
154
+ if (await exists(file.path))
155
+ throw Error(`Already exists: ${file.path}. Nothing was changed. Use --check to see the remaining steps, or --resume to finish package installation.`);
156
+ }
157
+ return { platform, auth, files: resume ? [] : files, replacements: resume ? [] : replacements, commands, notes: resume ? ['Resume package installation. Existing integration files, permission code, screens and environment files are preserved.'] : notes, setupFile: join(client.root, '.robono/setup.json') };
158
+ }
159
+ /** Reconstruct trusted package commands; never execute commands from saved metadata. */
160
+ export async function planMessengerInstallationResume(directory) {
161
+ const root = resolve(directory), setupFile = join(root, '.robono/setup.json');
162
+ await safePath(setupFile);
163
+ const setup = JSON.parse(await readFile(setupFile, 'utf8'));
164
+ if (![1, 2].includes(setup.schema) || !['web', 'expo'].includes(setup.platform) || !['custom', 'supabase', 'firebase'].includes(setup.auth) || typeof setup.origin !== 'string' || typeof setup.serverProject !== 'string' || setup.companyName !== undefined && typeof setup.companyName !== 'string')
165
+ throw Error('Invalid saved setup. Keep your integration files and run --check.');
166
+ const server = resolve(root, setup.serverProject), base = await appRoot(server);
167
+ const clientBase = setup.platform === 'web' ? base : (await exists(join(root, 'src')) ? 'src' : '');
168
+ for (const file of [join(root, clientBase, 'robono/client/ConnectedRobonoChat.tsx'), join(server, base, 'app/api/robono/route.ts')]) {
169
+ await safePath(file);
170
+ if (!await exists(file))
171
+ throw Error('An integration file is missing. Restore it before resuming; existing code was not changed.');
172
+ }
173
+ return createInstallationPlan({ project: root, ...(setup.platform === 'expo' ? { serverProject: server } : {}), origin: setup.origin, companyName: setup.companyName ?? 'Your company', auth: setup.auth }, true);
174
+ }
175
+ /** Preflight all files; exclusive writes and rollback avoid overwriting customer work. */
176
+ export async function applyMessengerInstallation(plan, install = true) {
177
+ for (const file of plan.files) {
178
+ await safePath(file.path);
179
+ if (await exists(file.path))
180
+ throw Error(`Already exists: ${file.path}. Nothing was changed.`);
181
+ }
182
+ for (const file of plan.replacements) {
183
+ await safePath(file.path);
184
+ if (await readFile(file.path, 'utf8') !== file.before)
185
+ throw Error('Project configuration changed after planning. Run the installer again.');
186
+ }
187
+ const written = [], replaced = [];
188
+ try {
189
+ for (const file of plan.files) {
190
+ await mkdir(dirname(file.path), { recursive: true });
191
+ await writeFile(file.path, file.content, { flag: 'wx', mode: 0o600 });
192
+ written.push(file.path);
193
+ }
194
+ for (const file of plan.replacements) {
195
+ if (await readFile(file.path, 'utf8') !== file.before)
196
+ throw Error('Project configuration changed during installation. Existing configuration was preserved.');
197
+ await writeFile(file.path, file.after);
198
+ replaced.push(file);
199
+ }
200
+ }
201
+ catch (e) {
202
+ for (const file of replaced)
203
+ await writeFile(file.path, file.before);
204
+ for (const file of written)
205
+ await unlink(file);
206
+ throw e;
207
+ }
208
+ if (install)
209
+ for (const command of plan.commands)
210
+ await new Promise((resolve, reject) => {
211
+ const child = spawn(command.command, command.args, { cwd: command.cwd, stdio: 'inherit', shell: false, env: { ...process.env, npm_config_ignore_scripts: 'true', YARN_ENABLE_SCRIPTS: 'false' } });
212
+ child.on('error', () => reject(Error('Package installation could not start. Files are saved; fix the package-manager error and run robono-messenger-install --resume.')));
213
+ child.on('exit', code => code === 0 ? resolve() : reject(Error('Package installation did not finish. Files are saved; fix the package-manager error and run robono-messenger-install --resume.')));
214
+ });
215
+ if (install && plan.setupFile) {
216
+ await safePath(plan.setupFile);
217
+ const setup = JSON.parse(await readFile(plan.setupFile, 'utf8'));
218
+ setup.dependencies = 'installed';
219
+ await writeFile(plan.setupFile, JSON.stringify(setup, null, 2) + '\n');
220
+ }
221
+ }
222
+ export async function messengerInstallationChecklist(directory) {
223
+ const root = resolve(directory), file = join(root, '.robono/setup.json');
224
+ if (!await exists(file))
225
+ return [{ name: 'Install files', status: 'needs-action', detail: 'Run robono-messenger-install in your Next.js or Expo project.' }];
226
+ const setup = JSON.parse(await readFile(file, 'utf8'));
227
+ const server = resolve(root, setup.serverProject), base = await appRoot(server);
228
+ const permissionFile = join(server, base, 'robono/server/robono-access.ts');
229
+ const permission = await readFile(permissionFile, 'utf8').catch(() => 'Connect your existing messaging permission');
230
+ const missingPermission = permission.includes('Connect your existing messaging permission');
231
+ const clientBase = setup.platform === 'web' ? base : (await exists(join(root, 'src')) ? 'src' : '');
232
+ const complete = await exists(join(root, clientBase, 'robono/client/ConnectedRobonoChat.tsx')) && await exists(join(server, base, 'app/api/robono/route.ts'));
233
+ return [
234
+ { name: 'Prepared files', status: complete ? 'passed' : 'failed', detail: `${setup.platform === 'expo' ? 'Expo' : 'Next.js'}; ${setup.auth} sign-in.` },
235
+ { name: 'Package installation', status: setup.dependencies === 'installed' ? 'configured' : 'needs-action', detail: setup.dependencies === 'installed' ? 'The package manager completed. Use the in-app connection check next.' : 'Run robono-messenger-install --resume to finish installing packages without replacing your integration files.' },
236
+ { name: 'Account permissions', status: missingPermission ? 'needs-action' : 'configured', detail: missingPermission ? 'Connect robono-access.ts to your current permission rules. The live checker verifies a test account.' : 'Permission code was edited. Verify allowed and denied accounts; file inspection cannot prove your rules.' },
237
+ { name: 'Server key', status: 'needs-action', detail: 'Set ROBONO_API_KEY on the server. This command does not read or print secrets.' },
238
+ { name: 'Contacts', status: 'needs-action', detail: 'Synchronize permitted friendships and group membership; pass names/avatars to ConnectedRobonoChat.' },
239
+ { name: 'Notifications', status: 'needs-action', detail: setup.platform === 'expo' ? 'Add Apple/Google credentials in Robono and rebuild both apps with the installed Expo plugins.' : 'Connect your website origin and chat path in Robono.' },
240
+ { name: 'Live connection', status: 'needs-action', detail: 'Set ROBONO_MESSENGER_URL, ROBONO_WEBSITE_ORIGIN and ROBONO_TEST_USER_TOKEN locally, then run robono-messenger-check.' },
241
+ ];
242
+ }
243
+ //# sourceMappingURL=messenger-install.js.map
@@ -0,0 +1,43 @@
1
+ /** Development-only, in-memory demonstration using the actual shipped messenger. */
2
+ export function messengerPreviewTemplates() {
3
+ return {
4
+ page: `import {notFound} from 'next/navigation';
5
+ import Preview from '../../robono/client/RobonoPreview';
6
+ export const dynamic='force-dynamic';
7
+ export default function RobonoPreviewPage(){if(process.env.NODE_ENV!=='development')notFound();return <Preview/>;}
8
+ `,
9
+ client: `'use client';
10
+ import React,{useEffect,useRef} from 'react';
11
+ import {mountRobonoMessenger} from '@robono/messaging-web';
12
+ import {defaultMessagingSettings} from '@robono/messaging';
13
+ export default function RobonoPreview(){
14
+ const alice=useRef<HTMLDivElement>(null),bob=useRef<HTMLDivElement>(null);
15
+ useEffect(()=>{
16
+ if(process.env.NODE_ENV!=='development'||!alice.current||!bob.current)return;
17
+ const settings=defaultMessagingSettings();settings.enabled=true;
18
+ const messages:any[]=[];
19
+ const transport=(userId:string):typeof fetch=>async(_request,init)=>{
20
+ const body=JSON.parse(String(init?.body??'{}')),other=userId==='alice'?'bob':'alice';let result:unknown;
21
+ switch(body.operation){
22
+ case 'settings':result={settings};break;
23
+ case 'conversations':result={appearance:settings.appearance,conversations:[{id:'demo-chat',kind:'direct',contactId:other,lastMessage:messages.at(-1)??null,unreadCount:0,canSend:true}],hasMore:false,nextCursor:null};break;
24
+ case 'list':result={settings,messages:[...messages],hasMore:false,nextCursor:null,conversation:{kind:'direct',canSend:true}};break;
25
+ case 'send':{
26
+ let message=messages.find(m=>m.senderId===userId&&m.clientMessageId===body.clientMessageId);
27
+ if(!message){message={...body,id:String(messages.length+1),senderId:userId,createdAt:new Date().toISOString(),readAt:null};messages.push(message);}
28
+ result={message};break;
29
+ }
30
+ case 'read':for(const m of messages)if(m.senderId!==userId)m.readAt=new Date().toISOString();result={ok:true};break;
31
+ default:return Response.json({error:'This local preview demonstrates text only. Connect your test account to try other features.'},{status:400});
32
+ }
33
+ return Response.json(result);
34
+ };
35
+ const mount=(element:HTMLDivElement,userId:string)=>mountRobonoMessenger(element,{companyName:'Example company',userId,getAccessToken:async()=>'local-preview-only',contacts:{alice:{name:'Alice'},bob:{name:'Bob'}},endpoint:'/local-preview-never-sent',fetch:transport(userId),notifications:false,mediaUrl:false,persistence:false});
36
+ const a=mount(alice.current,'alice'),b=mount(bob.current,'bob');return()=>{a.unmount();b.unmount();};
37
+ },[]);
38
+ return <main style={{padding:16,fontFamily:'system-ui'}}><h1>Try a conversation</h1><p>Local preview · Fictional Alice and Bob · Text stays in this page and disappears on reload. This does not test your live connection.</p><p>Open Bob on the left and Alice on the right, then send a message.</p><div style={{display:'flex',flexWrap:'wrap',gap:16}}><section style={{flex:'1 1 320px'}}><h2>Alice</h2><div ref={alice} style={{height:640}}/></section><section style={{flex:'1 1 320px'}}><h2>Bob</h2><div ref={bob} style={{height:640}}/></section></div></main>;
39
+ }
40
+ `
41
+ };
42
+ }
43
+ //# sourceMappingURL=messenger-preview-template.js.map
@@ -0,0 +1 @@
1
+ {"type":"module","private":true}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robono/server",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Provider-neutral server SDK for the Robono Bridge API.",
5
5
  "keywords": [
6
6
  "robono",
@@ -43,6 +43,14 @@
43
43
  "./installer": {
44
44
  "types": "./dist/messenger-install.d.ts",
45
45
  "import": "./dist/messenger-install.js"
46
+ },
47
+ "./node": {
48
+ "types": "./dist/messenger-node.d.ts",
49
+ "import": "./dist/messenger-node.js"
50
+ },
51
+ "./upgrade": {
52
+ "types": "./dist/messenger-upgrade.d.ts",
53
+ "import": "./dist/messenger-upgrade.js"
46
54
  }
47
55
  },
48
56
  "bin": {
@@ -60,7 +68,8 @@
60
68
  "SBOM.spdx.json",
61
69
  "sql",
62
70
  "NOTIFICATIONS.md",
63
- "SCREENING.md"
71
+ "SCREENING.md",
72
+ "legacy"
64
73
  ],
65
74
  "scripts": {
66
75
  "build": "tsc -p tsconfig.json",