2026-06-20 23:59:24 -05:00
"use strict" ;
var _ _createBinding = ( this && this . _ _createBinding ) || ( Object . create ? ( function ( o , m , k , k2 ) {
if ( k2 === undefined ) k2 = k ;
var desc = Object . getOwnPropertyDescriptor ( m , k ) ;
if ( ! desc || ( "get" in desc ? ! m . _ _esModule : desc . writable || desc . configurable ) ) {
desc = { enumerable : true , get : function ( ) { return m [ k ] ; } } ;
}
Object . defineProperty ( o , k2 , desc ) ;
} ) : ( function ( o , m , k , k2 ) {
if ( k2 === undefined ) k2 = k ;
o [ k2 ] = m [ k ] ;
} ) ) ;
var _ _setModuleDefault = ( this && this . _ _setModuleDefault ) || ( Object . create ? ( function ( o , v ) {
Object . defineProperty ( o , "default" , { enumerable : true , value : v } ) ;
} ) : function ( o , v ) {
o [ "default" ] = v ;
} ) ;
var _ _importStar = ( this && this . _ _importStar ) || ( function ( ) {
var ownKeys = function ( o ) {
ownKeys = Object . getOwnPropertyNames || function ( o ) {
var ar = [ ] ;
for ( var k in o ) if ( Object . prototype . hasOwnProperty . call ( o , k ) ) ar [ ar . length ] = k ;
return ar ;
} ;
return ownKeys ( o ) ;
} ;
return function ( mod ) {
if ( mod && mod . _ _esModule ) return mod ;
var result = { } ;
if ( mod != null ) for ( var k = ownKeys ( mod ) , i = 0 ; i < k . length ; i ++ ) if ( k [ i ] !== "default" ) _ _createBinding ( result , mod , k [ i ] ) ;
_ _setModuleDefault ( result , mod ) ;
return result ;
} ;
} ) ( ) ;
Object . defineProperty ( exports , "__esModule" , { value : true } ) ;
exports . createInviteCallable = void 0 ;
const functions = _ _importStar ( require ( "firebase-functions" ) ) ;
const admin = _ _importStar ( require ( "firebase-admin" ) ) ;
/ * *
* HTTPS callable that creates a secure invite code .
*
* Issue # 9 / review2 . md Risk # 1 fix : clients are no longer allowed to create
* invites directly . 6 - character document IDs are enumerable , so a direct client
2026-06-21 11:20:48 -05:00
* write would expose pending invites to scanning .
2026-06-20 23:59:24 -05:00
*
2026-06-21 11:20:48 -05:00
* Request body : { code ? : string , wrappedCoupleKey ? : string , kdfSalt ? : string , kdfParams ? : string , encryptedRecoveryPhrase ? : string }
* - code : client - supplied 6 - character code ( Android ) . The server validates uniqueness and
* returns an error if taken so the client can retry with a new code . If omitted ( iOS ) ,
* the server generates the code .
2026-06-20 23:59:24 -05:00
* - wrappedCoupleKey : base64 - encoded couple key wrapped by the inviter ' s KDF
* - kdfSalt : base64 KDF salt
* - kdfParams : KDF parameter tag ( e . g . argon2id ; v = 19 ; m = 47104 ; t = 3 ; p = 1 )
2026-06-21 11:20:48 -05:00
* - encryptedRecoveryPhrase : Argon2id + AES - GCM blob produced by the Android client using
* the invite code as the KDF input . The server stores it opaquely and never sees the
* plaintext phrase . Omitted by iOS until iOS implements E2EE parity .
2026-06-20 23:59:24 -05:00
*
* When E2EE fields are omitted the function writes nulls ; iOS MVP creates
* plaintext couples ( encryptionVersion = 0 on the resulting couple ) and does not
* supply these fields . Android always supplies them .
*
* Response : { code : string , expiresAt : Timestamp }
*
* Operations ( all via Admin SDK , so Firestore rules are bypassed ) :
* 1. Verify caller is authenticated and not already paired .
* 2. Rate - limit the caller to 5 invite creations per rolling hour .
2026-06-21 11:20:48 -05:00
* 3. Use client - supplied code or generate one server - side ; validate uniqueness via transaction .
2026-06-20 23:59:24 -05:00
* 4. Write the invite document with a 24 - hour TTL .
* /
const CODE _CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' ;
const CODE _LENGTH = 6 ;
const INVITE _TTL _MS = 24 * 60 * 60 * 1000 ;
const RATE _LIMIT _WINDOW _MS = 60 * 60 * 1000 ;
const RATE _LIMIT _MAX = 5 ;
function generateCode ( ) {
let code = '' ;
const randomValues = Buffer . alloc ( CODE _LENGTH ) ;
// crypto.randomBytes is synchronous and suitable for Cloud Functions.
require ( 'crypto' ) . randomFillSync ( randomValues ) ;
for ( let i = 0 ; i < CODE _LENGTH ; i ++ ) {
code += CODE _CHARS [ randomValues [ i ] % CODE _CHARS . length ] ;
}
return code ;
}
exports . createInviteCallable = functions . https . onCall ( async ( data , context ) => {
var _a , _b , _c ;
const callerId = ( _a = context . auth ) === null || _a === void 0 ? void 0 : _a . uid ;
if ( ! callerId ) {
throw new functions . https . HttpsError ( 'unauthenticated' , 'Must be signed in.' ) ;
}
const db = admin . firestore ( ) ;
// Caller must not already be paired.
const callerDoc = await db . collection ( 'users' ) . doc ( callerId ) . get ( ) ;
if ( callerDoc . exists && ( ( _b = callerDoc . data ( ) ) === null || _b === void 0 ? void 0 : _b . coupleId ) != null ) {
throw new functions . https . HttpsError ( 'failed-precondition' , 'Caller is already paired.' ) ;
}
const callerDisplayName = ( _c = callerDoc . data ( ) ) === null || _c === void 0 ? void 0 : _c . displayName ;
// Rate limit: count invites created by this user in the last hour.
const now = admin . firestore . Timestamp . now ( ) ;
const windowStart = admin . firestore . Timestamp . fromMillis ( now . toMillis ( ) - RATE _LIMIT _WINDOW _MS ) ;
const recentInvitesQuery = db
. collection ( 'invites' )
. where ( 'inviterUserId' , '==' , callerId )
. where ( 'createdAt' , '>=' , windowStart )
. orderBy ( 'createdAt' , 'desc' )
. limit ( RATE _LIMIT _MAX + 1 ) ;
const recentInvites = await recentInvitesQuery . get ( ) ;
if ( recentInvites . size >= RATE _LIMIT _MAX ) {
throw new functions . https . HttpsError ( 'resource-exhausted' , 'Too many invites created. Try again later.' ) ;
}
2026-06-21 11:20:48 -05:00
const clientCode = data === null || data === void 0 ? void 0 : data . code ;
2026-06-20 23:59:24 -05:00
const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data . wrappedCoupleKey ;
const kdfSalt = data === null || data === void 0 ? void 0 : data . kdfSalt ;
const kdfParams = data === null || data === void 0 ? void 0 : data . kdfParams ;
2026-06-21 11:20:48 -05:00
const encryptedRecoveryPhrase = data === null || data === void 0 ? void 0 : data . encryptedRecoveryPhrase ;
2026-06-20 23:59:24 -05:00
// E2EE fields must be supplied together or omitted together.
const e2eeFields = [ wrappedCoupleKey , kdfSalt , kdfParams ] ;
const suppliedE2ee = e2eeFields . filter ( ( v ) => v != null ) . length ;
if ( suppliedE2ee > 0 && suppliedE2ee < e2eeFields . length ) {
throw new functions . https . HttpsError ( 'invalid-argument' , 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.' ) ;
}
const expiresAt = admin . firestore . Timestamp . fromMillis ( now . toMillis ( ) + INVITE _TTL _MS ) ;
2026-06-21 11:20:48 -05:00
// Android supplies its own code (used as the KDF input for phrase encryption, so the server
// must use it as-is). iOS omits the code; the server generates one in that case.
// Either way, validate uniqueness via transaction and return an error on collision so the
// client can retry with a fresh code.
2026-06-20 23:59:24 -05:00
let inviteRef = null ;
let code = null ;
2026-06-21 11:20:48 -05:00
const candidates = clientCode ? [ clientCode ] : Array . from ( { length : 10 } , generateCode ) ;
for ( const candidate of candidates ) {
2026-06-20 23:59:24 -05:00
const candidateRef = db . collection ( 'invites' ) . doc ( candidate ) ;
// eslint-disable-next-line no-await-in-loop
const created = await db . runTransaction ( async ( tx ) => {
const snap = await tx . get ( candidateRef ) ;
2026-06-21 11:20:48 -05:00
if ( snap . exists )
2026-06-20 23:59:24 -05:00
return false ;
tx . set ( candidateRef , {
code : candidate ,
inviterUserId : callerId ,
inviterDisplayName : callerDisplayName !== null && callerDisplayName !== void 0 ? callerDisplayName : null ,
status : 'pending' ,
createdAt : admin . firestore . FieldValue . serverTimestamp ( ) ,
expiresAt ,
usedAt : null ,
usedByUserId : null ,
wrappedCoupleKey : wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null ,
kdfSalt : kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null ,
kdfParams : kdfParams !== null && kdfParams !== void 0 ? kdfParams : null ,
2026-06-21 11:20:48 -05:00
encryptedRecoveryPhrase : encryptedRecoveryPhrase !== null && encryptedRecoveryPhrase !== void 0 ? encryptedRecoveryPhrase : null ,
2026-06-20 23:59:24 -05:00
} ) ;
return true ;
} ) ;
if ( created ) {
code = candidate ;
inviteRef = candidateRef ;
break ;
}
}
if ( ! code || ! inviteRef ) {
2026-06-21 11:20:48 -05:00
// Client-supplied code collided; the Android client will retry with a new code.
throw new functions . https . HttpsError ( 'already-exists' , 'Invite code is already taken. Please try again.' ) ;
2026-06-20 23:59:24 -05:00
}
// Write a server-side audit log entry for the inviter. This is not read by
// clients and supports the rate-limit count as well as future abuse review.
try {
await db . collection ( 'users' ) . doc ( callerId ) . collection ( 'notification_queue' ) . add ( {
type : 'invite_created' ,
inviteCode : code ,
createdAt : admin . firestore . FieldValue . serverTimestamp ( ) ,
read : true ,
} ) ;
}
catch ( err ) {
// Audit write is best-effort; do not fail the invite if it errors.
console . warn ( ` [createInviteCallable] audit log failed for ${ callerId } : ` , err ) ;
}
console . log ( ` [createInviteCallable] ${ callerId } created invite ${ code } ; expires ${ expiresAt . toDate ( ) . toISOString ( ) } ` ) ;
return { code , expiresAt } ;
} ) ;
//# sourceMappingURL=createInviteCallable.js.map