diff --git a/Dockerfile b/Dockerfile index c4e5b4e..cbe3c55 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,18 @@ WORKDIR /app/server COPY server/package.json server/package-lock.json* ./ RUN npm ci --omit=dev --ignore-scripts 2>/dev/null || npm install --omit=dev --ignore-scripts +# better-sqlite3 is a native (C++) module — `--ignore-scripts` above +# skips the postinstall hook that fetches its prebuilt binary for our +# platform. Rebuild it explicitly so prebuild-install runs. python3 + +# make + g++ are the fallback toolchain if no prebuilt matches (e.g. +# on uncommon arches); on linux-x64/arm64 the prebuild downloads in +# seconds and the compiler is never invoked. This stage is discarded +# from the final image, so the install footprint doesn't matter. +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ \ + && npm rebuild better-sqlite3 \ + && rm -rf /var/lib/apt/lists/* + # ── Stage 2: Final runtime image ─────────────────────────── FROM node:20-slim AS runner diff --git a/assets/qrcode.min.js b/assets/qrcode.min.js new file mode 100644 index 0000000..76889b5 --- /dev/null +++ b/assets/qrcode.min.js @@ -0,0 +1,2297 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(row * cellSize, col * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); diff --git a/docs/architecture-simplification-plan.md b/docs/architecture-simplification-plan.md new file mode 100644 index 0000000..210f3ab --- /dev/null +++ b/docs/architecture-simplification-plan.md @@ -0,0 +1,261 @@ +# Recaps Architecture Simplification — Plan of Record + +**Status:** Agreed direction, 2026-05-19. Not yet implemented. + +## Why this exists + +The current Recaps architecture has the Keysat license doing too many jobs. +For a cloud Pro/Max tenant, the license is acting as: (a) the entitlement +check, (b) the relay credit-pool key, (c) the subscription-expiry source, +AND (d) the take-it-home portability token. Only (d) actually requires a +cryptographically-signed token. The other three are accidental — the +license just happens to carry that data because Keysat was already minting +license tokens during the MVP. + +This doc captures the agreed simplification so we can revisit later before +implementation. + +--- + +## The three products, cleanly separated + +**Recap Relay** — Backend compute service. AI provider routing +(Gemini / Claude / Whisper / Ollama / etc.), credit ledger, BTCPay-backed +top-ups. Runs on Grant's StartOS. Sold as a service (subscription gives +monthly credit allotment + a la carte top-ups). + +**Recaps** — Frontend cloud SaaS for summarizing podcasts/videos. People +sign up, pay a subscription, and use the relay for compute. Hosted at +`recaps.cc`. Also freely available as a `.s9pk` for self-hosting. + +**Keysat** — Standalone licensing-as-a-service software, separately +monetized as a B2B product. Recaps happens to use Keysat for the one +specific case of minting a portable license token when a cloud user +clicks "Take Recaps home." Otherwise unrelated to Recaps day-to-day. + +These are three products that happen to share an author. Tangling them is +what made the current architecture confusing. + +--- + +## What each layer owns after the simplification + +| Concern | Lives in | Why | +|---|---|---| +| Subscription tier + `expires_at` | **Recaps DB** (`users.tier`, `users.subscription_expires_at`) | Billing state belongs with the billing surface. Zaprite/BTCPay webhooks fire at Recaps and update one row. | +| Credit balance (remaining/consumed) | **Relay's ledger** (`credits.json`) | Compute happens at the relay. The relay knows when N credits were actually spent on Gemini tokens. Race conditions get ugly if billing and consumption drift. | +| Purchased credit top-ups (one-shot Lightning) | **Relay's ledger** | BTCPay webhook → relay → bump `purchased_balance` on the relevant pool. Unchanged from today. | +| Monthly tier allotment | **Relay computes** from tier header passed by Recaps | Recaps sends `X-Recap-User-Tier: pro`; relay applies its quota config (`pro.monthly = 50`). | +| Per-user identity at the relay | Keyed by `user:` (cloud) or `lic:` (self-hosted) | Removes the license-as-credential coupling for cloud requests. License only matters for self-hosted. | + +### The header change + +``` +Cloud Recaps → Relay (today): + Authorization: Bearer + +Cloud Recaps → Relay (after simplification): + X-Recap-User-Id: + X-Recap-User-Tier: pro + Authorization: Bearer ← proves THIS Recaps server is authorized +``` + +The operator's bearer token is still needed because the relay needs to +verify that this is Grant's cloud Recaps server (vs. someone else trying +to forge user-id headers). The per-user identity comes from the explicit +headers. + +--- + +## Cloud Recaps is paid-only + +**Decided:** cloud Recaps drops the "free signed-in" tier entirely. Self- +hosted IS the free path. + +User states on cloud: +- **Anon trial** — cookie-tracked, no account. Gets a small allowance of + credits to taste-test. After the trial runs out, must subscribe to + continue using the cloud service. +- **Subscribed** — account exists, `users.tier ∈ {pro, max}`, + `subscription_expires_at` in the future. +- **Expired subscription** — account preserves the library, can't + summarize new things until renewed. Renewal anytime restores access. + +What this means for the codebase: `tenant_credits` table's "free signed-in" +codepath stops applying to cloud users. The table stays in the codebase +because self-hosted multi-tenant operators (Alice running Recaps for her +family) still need per-tenant accounting locally. + +--- + +## Payment provider strategy + +**Pro/Max purchase page** has two paths: + +``` +┌─────────────────────────────────────────────┐ +│ Upgrade to Pro — $X/month │ +│ │ +│ • 50 Recap credits per month │ +│ • Channel + podcast subscriptions │ +│ • Auto-queue + priority processing │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ ⚡ Pay with Bitcoin │ │ ← primary, inline BTCPay +│ └──────────────────────────────────┘ │ +│ │ +│ Pay with card → │ ← link → Zaprite hosted +│ │ +└─────────────────────────────────────────────┘ +``` + +### Bitcoin path: monthly upfront, manual renewal + +- User pays one BTCPay invoice for 30 days of Pro +- Inline Lightning QR + BOLT11 + copy button (same UX as credit packs) +- On settle, Recaps sets `subscription_expires_at = now + 30 days` +- No card on file, no autorenewal — Lightning doesn't have a clean + recurring-billing primitive + +### Renewal-link emails (NEW, needed for Bitcoin path) + +Recaps sends automated emails near expiry: +- **7 days before expiry** — "Your Pro sub renews in 7 days. Tap to renew →" +- **Day of expiry** — "Your Pro sub expired. Tap to renew →" +- **7 days after expiry** — "We've paused your Pro features. Renew anytime →" +- After 30 days post-expiry — stop emailing (avoid being a nag) + +Mechanism: +- Email contains a tokenized URL: `https://recaps.cc/renew?token=` +- Token encodes `{ user_id, action: "renew_pro" }`, short-lived (~14 days) +- Click → Recaps mints fresh BTCPay invoice, renders inline Lightning UI +- On settle, `subscription_expires_at += 30 days` (extends from whichever + is later: existing expiry, or current time — so a user who renews 5 + days early doesn't lose those days) + +Card subscribers don't need this — Zaprite handles recurring billing +natively via Stripe. + +### Card path: Zaprite handles everything + +- Click "Pay with card" → redirect to Zaprite hosted checkout +- Zaprite collects card info, charges monthly, handles retries on failure +- Zaprite webhook fires → Recaps updates `subscription_expires_at` +- Cancel button in Settings → Plan calls Zaprite cancel API +- **Card premium pricing handled inside Zaprite admin** (not in Recaps + code) — Grant configures the card-monthly price to be N% higher than + Bitcoin-monthly in Zaprite's product settings + +--- + +## Self-hosted scenarios + +**Self-hosted Recaps is free + open source.** Run the `.s9pk` anywhere, +no license check to run the software. + +| Scenario | Subscriber to Grant's relay? | How relay access works | +|---|---|---| +| Bob runs Recaps for himself, brings his own Gemini key | No | Bob pays Google directly. Default `.s9pk` has no relay configured — Bob enters his AI provider keys in Settings. | +| Bob runs Recaps for himself + wants Grant's relay | Yes | Bob has a cloud Pro subscription. He clicks "Take Recaps home" in cloud settings → Recaps mints a fresh LIC1 token via Keysat with `expires_at = subscription_expires_at`. Bob pastes it into his self-hosted install. Self-hosted Recaps uses the token as `Authorization: Bearer LIC1-...` to the relay. | +| Alice runs Recaps for family, brings own Gemini key | No | Alice's family is invisible to Grant. | +| Alice runs Recaps for family + uses Grant's relay | Yes | Alice has a cloud subscription. Family-tenants on Alice's install all share Alice's relay-credit pool via Alice's pasted license. Alice manages per-family-member accounting locally via the existing `tenant_credits` + operator-grant flow. If Alice needs more credit headroom for her family, she upgrades her cloud sub to Max or buys credit packs. | + +### What the license is for, after the simplification + +**One job: the credential that authenticates a self-hosted Recaps install +against Grant's relay.** + +Not "are you Pro on cloud" — Recaps DB knows that. +Not "credit pool key" — `user:` keys cloud requests, `lic:` keys self-hosted. +Not "subscription expiry" — `users.subscription_expires_at` in Recaps DB is the source of truth. + +The license is a **deliverable artifact**, minted on demand only when a +cloud Pro user explicitly clicks "Take Recaps home." Most cloud users +never click it; they don't need a license. The license's `expires_at` +mirrors the user's subscription expiry, so when their cloud sub lapses, +the self-hosted install's relay access stops working naturally. + +### Grace period for self-hosted licenses + +**Decided:** Keysat handles this. When a cloud subscription lapses, Keysat +can keep the license valid for a short grace period (e.g., 7 days) before +revoking. Recaps doesn't manage this — it's a Keysat-internal policy. + +--- + +## Migration order + +When ready to implement: + +1. **Decide self-hosted = free + open source** ← already decided. Removes + the "is this install licensed" check from the cloud path; keeps it + only as a guard on relay access. +2. **Recaps schema additions:** `users.tier`, `users.subscription_expires_at`, + `users.zaprite_customer_id`, `users.zaprite_subscription_id`, + `users.bitcoin_renewal_token_hash` (single-use). Migrate existing users + by deriving tier + expires_at from their attached license at boot time. +3. **Zaprite webhook handler:** Recaps endpoint accepting Zaprite's order + + subscription lifecycle events; updates user row accordingly. +4. **Relay header migration:** Cloud Recaps sends `X-Recap-User-Id` + + `X-Recap-User-Tier`. Relay accepts BOTH old (license-keyed) and new + (user-id-keyed) headers for one release as a compat window. +5. **Drop license attachment from cloud signup:** Pro/Max purchase via + Zaprite or BTCPay now updates `users.tier` + `users.subscription_expires_at` + directly. No LIC1 token attached at signup. +6. **Renewal-email pipeline:** Scheduled job in Recaps that scans for + subscriptions approaching expiry, sends the renewal email with a + one-time-use renewal token. Token consumption flow on `/renew?token=…`. +7. **Pro/Max purchase UX redesign:** Bitcoin primary + Card secondary + layout. Bitcoin path uses existing inline BTCPay flow. Card path + redirects to Zaprite hosted checkout. +8. **"Take Recaps home" rework:** Becomes an explicit user-initiated + action that mints a fresh LIC1 token via Keysat at click time, not at + signup. UI shows the token with copy-to-clipboard + install + instructions. +9. **Cancel-subscription button:** Settings → Plan → cancel. Hits Zaprite + cancel API for card subs; for Bitcoin subs there's nothing to cancel + (just let it lapse — they paid one month, they get one month). +10. **Remove "free signed-in" path from cloud:** Anon trial credits stay + as taste-test; signup grant goes away. Self-hosted is the free path + going forward. + +Estimated effort: ~1 week of focused work end-to-end. Steps 2–5 are the +core; the rest are polish on top. + +--- + +## Decided open questions + +| Q | Decision | +|---|---| +| Annual or monthly Bitcoin subscription? | **Monthly upfront, manual renewal** with automated renewal-link emails | +| Same price across paths, or premium/discount? | **Card premium configured inside Zaprite admin**, not in Recaps code | +| Grace period on self-hosted licenses when cloud sub lapses? | **Keysat handles this** — Recaps doesn't manage | +| Free tier on cloud? | **No — cloud is paid-only**, self-hosted is the free path | + +--- + +## What stays (don't break) + +- Relay's credit ledger + tier-quota math +- BTCPay webhook → relay-pool crediting (for one-shot Lightning credit packs) +- Inline Lightning UX for credit packs (already shipped, working) +- Anon trial mechanic (cookie-tracked taste-test) +- The "Take Recaps home" feature itself (just changes when the token is minted) +- The Keysat license-issuing pipeline (just changes who calls it and when) + +## What changes + +- Recaps stops attaching a license at every Pro/Max signup +- Recaps gains its own subscription state (`users.tier` + `expires_at`) +- Relay accepts `X-Recap-User-Id` for cloud requests (compat with old license-keyed for self-hosted) +- Pro/Max purchase UX gets the two-option layout (Bitcoin primary, Card link) +- New renewal-email pipeline for Bitcoin subscribers +- Cancel-subscription button wired to Zaprite +- Cloud loses the "free signed-in" tier; self-hosted IS the free path +- "Take Recaps home" reshapes as on-demand mint, surfaced as an explicit + button in cloud settings + +## What's deleted + +- Nothing — only deprecated. Old license attachment code stays as a fallback for one release window. After migration is fully verified, can be cleaned up in a follow-up. diff --git a/docs/core-decoupling-plan.md b/docs/core-decoupling-plan.md new file mode 100644 index 0000000..68b3505 --- /dev/null +++ b/docs/core-decoupling-plan.md @@ -0,0 +1,175 @@ +# Core Decoupling — Implementation Plan (relay-owns) + +**Status:** ✅ **Implemented + build-ready, 2026-06-04.** Both sides code- +complete, typecheck/syntax clean, unit tests green. Relay bumped to +`0.2.119`, Recaps app to `0.2.143`. Not yet installed/configured on-device — +see "Install + configure runbook" at the bottom. +Scoped slice of `architecture-simplification-plan.md`, with the May plan's +"Recaps-owns billing" reversed per Grant's decision. + +## Decisions locked (2026-06-03) + +1. **The Recap Relay owns the Pro/Max subscription**, keyed by the Recaps + **user-id** (not a Keysat license). Recaps reads each user's tier from + the relay to gate features. +2. **No credit-pool migration** — no real customers yet; clean cutover. +3. **Keysat leaves the cloud path entirely.** A cloud user has no license. + Keysat/licenses remain ONLY for the (future) self-hosted-operator case. +4. **Server auth = a shared "operator key."** The `recaps.cc` server proves + itself to the relay with a shared secret; it then vouches for its users + via `X-Recap-User-Id`. +5. **Self-serve subscription purchase is DEFERRED.** For this slice, tiers + are **operator-set** (Grant grants Pro/Max). Self-serve BTCPay/card + subscription buying + expiry + renewal = the later "payment" slice. + +## Goal (one sentence) + +Replace "every cloud relay request carries the user's Keysat license" with +"the `recaps.cc` server authenticates once with an operator key and passes +the user's account-id; the relay tracks that user's tier + credits." + +--- + +## How it works after the change + +``` +Cloud user's browser + │ (logged-in Recaps session cookie) + ▼ +recaps.cc server ──reads user's tier from relay, gates features──┐ + │ POST /relay/<...> │ + │ X-Recap-User-Id: │ + │ X-Recap-Operator-Key: ← proves it's │ + │ (NO per-user license bearer) Grant's server│ + ▼ │ +Recap Relay │ + • validates operator key → trusts the user-id │ + • credit pool keyed by user: │ + • stores that user's TIER (+ optional expiry) on the pool ─────┘ + • applies the tier's monthly credit quota +``` + +Self-hosted operators are unchanged: they send a license bearer and no +`X-Recap-User-Id`, so they keep the existing `lic:`/`inst:` path. + +--- + +## Relay changes (recap-relay/) + +1. **Identity resolver** (new helper used by every route in place of + `resolveLicense(auth)` + raw installId): if `X-Recap-User-Id` is present + AND `X-Recap-Operator-Key` matches config → identity = + `{ creditKey: "user:", source: "cloud" }`. Else existing license/ + install path (`credits.js` `getCreditKey` / `resolveLicense`). +2. **Tier-of-record on the pool** (`credits.js` ledger row): make `tier` + (+ optional `subscription_expires_at`) an authoritative, persisted field + for `user:` pools, instead of reading tier from a license each request. + Quota math (`getTierQuotas`) keys off it as today. +3. **Operator endpoint to set a user's tier** — + `POST /admin/users/:userId/tier { tier, expires_at? }` (admin-auth + gated). This is how tiers get set in this slice; the future self-serve + purchase flow writes the same field. +4. **Report tier + balance for a user-id** — extend `/relay/balance` (and + the status surface) to answer for the `user:` identity so Recaps can + read it. +5. **Config:** `relay_cloud_operator_key` (+ a StartOS action to set it, + Phase 1.5). + +## Recaps changes (recap/) + +6. **Cloud relay identity** (`providers/index.js` `pickRelayIdentity` + + `providers/relay.js` `buildHeaders`): multi-mode cloud user → send + `X-Recap-User-Id` + `X-Recap-Operator-Key` (from server config), DROP the + user-license bearer. Single-mode / self-hosted unchanged. +7. **Entitlement checks read the relay-reported tier** + (`license-middleware.js` multi-mode branch, `tts-routes.js` + `userHasTtsAccess`): derive tier from what the relay reports for this + user (cached on `req.user` / relay-status), not from a parsed license. + Single-mode keeps using the operator `LIC`. +8. **Stop attaching a Keysat license at cloud signup** + (`license-purchase.js`): cloud accounts no longer get a license. (The + existing flow stays available for the self-hosted operator-license case + only.) +9. **Config:** `recap_relay_operator_key` (server-side; never sent to the + browser). + +--- + +## Explicitly deferred (later "payment" slice) + +Self-serve Pro/Max subscription purchase (monthly BTCPay/card, expiry, +renewal emails, cancel) · removing the free signed-in tier · "Take Recaps +home" rework. None of these block the decoupling; tiers are operator-set +until then. + +## Testing / rollout + +1. Relay: identity resolver — cloud (valid key)→`user:` ; cloud (bad/no + key)→reject/fallback ; self-hosted→`lic:`/`inst:`. +2. Relay: operator-set tier → `/relay/balance` reports it → metered call + decrements the `user:` pool at the right quota. +3. Recaps: feature gates (clips, subscriptions, TTS) follow the relay tier. +4. Ship relay first (accepts both old + new), then Recaps cutover. Verify a + self-hosted-style license request still works. Both via `make install` / + sideload — **no registry deploys.** + +## Effort + +~**2–3 focused days** (smaller than the migration-laden version): ~1 on the +relay (resolver, tier-on-pool, operator endpoint, config), ~1 on Recaps +(headers, tier-read, gate rewiring), ~0.5 testing. + +## Sequencing (resolved 2026-06-03) + +Core decoupling ships first with **operator-set tiers**; self-serve +subscription purchase is the immediate next slice. Rationale: land the +structural de-licensing on its own and verify it, then add money-handling +code on a proven foundation rather than entangling a refactor with new +payment flows. No real customers yet, so no cost to this ordering. + +--- + +## What landed (2026-06-04) + +**Relay (`recap-relay/`, → 0.2.119):** `identity.js` resolver + +`verifyOperatorKey`; `credits.js` `setUserTier`/`getUserCreditRow` + +`creditKey` threading; `job-credits.js`/`envelope.js` `creditKey`; +`routes/user-tier.js` (`POST`/`GET /relay/user-tier`, operator-key authed); +balance/tts/transcribe/analyze/transcribe-url/summarize-url use +`resolveIdentity`; `config.js` `relay_cloud_operator_key`; admin Settings +expose it as a masked **"Cloud operator key"** field (dashboard + +`PUT /admin/settings`). + +**Recaps (`recap/`, → 0.2.143):** `db.js` `users.tier` column + +`migrateUsersTier`; `relay-state.js` `computeCreditKey` keys `user:`; +`providers/index.js` `pickRelayIdentity` emits the cloud identity for paid +users; `providers/relay.js` sends `X-Recap-User-Id`+`X-Recap-Operator-Key` +and adds `setRelayUserTier`/`getRelayUserTier`; `license.js` `viewForTier`; +`license-middleware.js` `/api/license-status` derives the view from +`req.user.tier`; `tts-routes.js` gate reads `req.user.tier`; **3 gates that +keyed off `!keysat_license` now exclude paid-tier users** so they aren't +misrouted to the free-tenant `tenant_credits` path +(`/api/relay/status` display, `/api/process` gate+debit); `config.js` polls +`recap_relay_operator_key` into a live binding; `relay-default.js` +`getRelayOperatorKey` reads env→live-binding; StartOS **"Set Relay Operator +Key"** action + config field; operator **Tenants panel** gets a per-row +tier badge + **Tier** selector (`POST /api/admin/tenants/:id/tier`, which +writes the relay first then caches `users.tier`). + +## Install + configure runbook + +1. `cd recap-relay && make x86 && make install` (relay 0.2.119). **Never** + `make deploy`/`redeploy` for the relay. +2. Relay dashboard → Settings → Endpoints & credentials → **Cloud operator + key** → paste a fresh secret (`openssl rand -hex 32`). Save. +3. `cd recap && make x86 && make install` (app 0.2.143). +4. Recaps StartOS → Actions → **Set Relay Operator Key** → paste the **same** + secret. (Picked up within one config poll — no restart.) +5. Sign in as operator → **Tenants** → open a user's row → **Tier** → **Max** + (or Pro). This writes the relay `user:` tier, then caches `users.tier`. + A 502 here means the two operator keys don't match — fix + retry. +6. As that user: confirm the **MAX/PRO badge**, the **Listen** (TTS) button, + that a summarize run is metered against the relay `user:` pool (not + `tenant_credits`), and that `/api/relay/status` shows the relay balance. +7. Regression: a self-hosted-style license request still works (license/ + install path untouched — additive). diff --git a/docs/path-2b-and-path-1-interweave.md b/docs/path-2b-and-path-1-interweave.md new file mode 100644 index 0000000..0830fa9 --- /dev/null +++ b/docs/path-2b-and-path-1-interweave.md @@ -0,0 +1,282 @@ +# Path 2B + Path 1 Interweave Plan + +Companion doc to `architecture-simplification-plan.md` (Path 1) and the +chat thread that proposed Path 2A (relay-only upload, ship first). + +This doc covers: + +1. **Path 2B** — bringing internal-meeting analysis into the Recaps + cloud frontend as a first-class feature alongside YouTube/podcast + summaries. +2. **How Path 2B depends on Path 1** — what Path 1 unlocks vs. what + could be partially built without it. +3. **Migration path** — how Path 2A's relay-only upload data flows + forward into Path 2B's cloud-side library. + +--- + +## Context + +Recap Relay (the operator-side backend) is now generic enough to +analyze ANY audio — not just YouTube/podcasts. The download step is +the only YouTube-specific code; everything downstream (transcribe → +diarize → cluster → analyze → polish) applies cleanly to arbitrary +audio. Path 2A exposes this via a relay-admin-only upload UI so +operator Grant can run internal meeting analysis on his own hardware +TODAY without waiting on Recaps multi-tenant work. + +Path 2B is the longer arc: same capability surfaced in the cloud +Recaps app, so signed-in users can submit private meeting audio, +manage it alongside their other content, and (optionally) share with +colleagues. + +--- + +## Path 1 (recap) state + +`architecture-simplification-plan.md` defines: + +- One Recaps binary, two modes via `RECAP_MODE=single|multi` env var +- Magic-link + optional password auth via StartOS SMTP +- Per-user library at `/data/history//.json` +- Per-user keysat license (mintable via Keysat admin API) +- BTCPay subscription + one-time credit-purchase flows +- Lite-settings UI for non-operator cloud users +- Self-hosted operator stays single-tenant by default; the .s9pk + ships free + open + +Status: written but not built. The relay-side work has continued in +parallel (FIFO queue, clustering suppression, polish pass) which +strengthens the case for Path 1 — the cloud user experience benefits +from all of it, and the keysat-license layer is increasingly +friction-without-value for cloud users who already have email-verified +accounts. + +--- + +## Path 2B — internal meetings in cloud Recaps + +### What it looks like + +A signed-in Recaps user gets a second submission affordance alongside +the existing "Paste a YouTube/podcast link" input: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Submit content │ +│ ○ Paste a YouTube/podcast link │ +│ ○ Upload audio file (private) [Choose file…] │ +│ │ +│ Title: [_____________________________] │ +│ Participants: [_____________________________] (opt) │ +│ Meeting type: [▼ default / 1:1 / all-hands / …] │ +│ │ +│ [ Summarize ] │ +└─────────────────────────────────────────────────────────┘ +``` + +The submission flows the same way YouTube submissions do: +Recaps-app → Relay (`/relay/v1/summarize-upload` for files, +existing `/relay/v1/summarize-url` for URLs). The relay handles both +via the same pipeline — only the input step differs (download vs. +multipart receive). + +### Library + rendering + +Each saved session in the cloud user's library has a `type` field: + +- `"youtube"` — existing rendering (video player + topics + transcript chips) +- `"podcast"` — existing podcast rendering (audio player + topics + transcript) +- `"meeting"` — new rendering: no media player; topics + transcript chips + expandable below each topic card; PLUS a "Meeting analysis" block at + the top with Decisions / Action Items / Open Questions / Key Quotes + (the structured-extras pass from Phase 2 of Path 2A) + +Library list view shows a small icon distinguishing meeting items so +the user can filter at a glance. + +### Privacy + sharing + +Meetings are PRIVATE by default — visible only to the submitting user. +Two later features: + +- **Share with team** — a meeting can be optionally shared with N + named cloud-Recaps users (each looks up by email). Other users see + it in a "Shared with me" library section. +- **Export to markdown / PDF** — already exists for YouTube content; + add the meeting-extras blocks to the export. + +### Audio handling + +- Uploaded audio goes from browser → Recaps-app (Node Express, + multipart middleware) → Recap Relay (forward as multipart to + `/relay/v1/summarize-upload`). +- Recaps-app's tmp file is deleted immediately after the relay + acknowledges receipt. +- Relay's tmp file is deleted after the pipeline completes. +- NEITHER side keeps the audio. The TRANSCRIPT + analysis are saved. + If the user wants to re-process (different prompt set), they'd + re-upload the audio. +- The transcript stays in the user's per-user library at + `/data/history//.json`, scoped + isolated. + +--- + +## Path 2B's hard prerequisite: Path 1 + +Path 2B fundamentally requires multi-tenant auth in Recaps. Without +Path 1: + +- No per-user library separation +- No way to know whether the meeting audio is private to a user vs. + visible to everyone running this Recaps instance +- No way to share with named other users +- No way to bill upload-heavy users differently from URL-only users + (uploads might warrant a different price tier given the storage + cost) + +You COULD build a stripped-down Path 2B on single-tenant Recaps — +operator uploads audio, it's saved to the operator's library, no +sharing. But that's roughly equivalent to Path 2A with a fancier UI, +just on the wrong side of the codebase split (Recaps-app vs. relay). +Not worth the duplication. + +So: **ship Path 2A as the immediate beachhead, do Path 1 next, then +Path 2B on top.** + +### What Path 1 unlocks (relevant to 2B) + +The Path 1 doc already covers most of what 2B needs: +- Per-user library at `/data/history//*.json` +- Auth-aware request scoping (`req.userId`) +- Per-user keysat license OR the simplified user-id + tier headers +- BTCPay subscription tracking (for billing upload-heavy use) + +Path 1's "Lite-settings panel for cloud users" already imagines a +post-auth Recaps UI without operator config noise. The submission +input would extend in 2B to add the upload option. + +The relay-side header migration Path 1 proposes (`X-Recap-User-Id` + +`X-Recap-User-Tier` replacing the bearer license token) is also +beneficial for 2B — uploads from a single user with N concurrent +browsers all carry the same user-id, so credit accounting is per-user +not per-install. + +--- + +## How Path 2A's data flows into Path 2B + +When Path 2A ships (relay-only upload, results saved to +`/data/internal-meetings/.json` on the relay), those summaries +are tied to the OPERATOR (Grant) — there's no cloud-user concept yet. + +When Path 2B lands: + +1. **Migration script** — walks `/data/internal-meetings/*.json` and + re-homes each entry under the operator's cloud user account + (`/data/history/owner/*.json` initially, then `/data/history/ + /*.json` after Path 1's owner→admin rename). +2. **Same JSON shape** — Path 2A should save in a shape compatible + with Path 2B's expected library shape (chunks + entries + speakers + + meeting-extras). One way to guarantee this: design the Phase 1 + save shape now to match what Recaps' `saveToHistory` produces for + `type=meeting`, even though no Recaps UI consumes it yet. +3. **No re-processing needed** — transcripts and analysis transfer + verbatim. The user just sees them appear in their cloud library. + +The relay-side upload endpoint (`/relay/v1/summarize-upload`) is the +same in both worlds. Path 2A calls it via the operator dashboard's +admin auth; Path 2B calls it via Recaps-app server proxying a +signed-in cloud user's POST. + +So the relay code path is built once and serves both. + +--- + +## Operator-editable prompt sets (Phase 3 in 2A; carries to 2B) + +The "meeting type" dropdown is operator-editable. Each set has: +- Name (e.g. "1:1 with direct report") +- Topic-analysis prompt template (replaces the YouTube/podcast version) +- Meeting-extras prompt template (Decisions / Action Items / ...) +- Optional metadata schema overrides (e.g. "this meeting type always + expects 2 participants") + +Stored in `relay_meeting_prompt_sets_json` config field. Operator +edits via the dashboard (similar to existing prompts panel). Cloud +Recaps users pick a set at submission time; the relay applies it +during analyze + polish. + +Default sets ship built-in: +- `default` — neutral meeting prompt, all sections enabled +- `1on1` — emphasizes Action Items + Open Questions; light on Decisions +- `all-hands` — emphasizes Decisions + Key Quotes; less actionable +- `customer-interview` — emphasizes Key Quotes + Open Questions; light + on Decisions +- `standup` — short-form; Action Items + Open Questions only + +--- + +## Suggested order of operations + +1. **Path 2A Phase 1** — relay-only upload, no extras, basic + topic+transcript rendering. ~2-3 days. +2. **Path 2A Phase 2** — meeting-extras analysis pass (Decisions / + Action Items / Open Questions / Key Quotes). ~1-2 days. +3. **Path 2A Phase 3** — prompt sets dropdown. ~1 day. +4. (Use Path 2A in production for some weeks; gather feedback on + prompts, output quality, UX.) +5. **Path 1** — multi-tenant Recaps. ~3-4 weeks per the existing + architecture-simplification doc, modulo amendments. +6. **Path 2B** — surface internal meetings in cloud Recaps. ~1.5-2 + weeks given Path 1 has shipped. Migrates the Path 2A artifacts + into the new per-user library. + +Total wall time: ~6-8 weeks for the full arc. Path 2A capability +available to operator after step 1 (~2-3 days). Cloud users get +meetings after step 6. + +--- + +## Open questions + +These should be settled BEFORE Path 2B build starts: + +1. **Pricing for uploads.** Does an upload count the same as a URL + submission against a user's monthly credit cap? Or is it priced + differently to reflect the upload bandwidth + storage cost? My + default: same price (1 credit per submission) — bandwidth cost is + trivial, storage is just JSON. + +2. **Audio retention.** Default: never retain. Optional per-user + setting "keep audio for 7/30/90 days so I can re-process with a + different prompt set"? Adds operator storage cost; only worth it + if users actually want it. + +3. **Sharing model.** Path 2B Phase 1 = no sharing, private only. + Phase 2 = shared with N named users. Phase 3 = optional public + share URL. Each phase adds auth/permission complexity. Worth + designing the data model now (`{ ownerId, sharedWith: [userId] }`) + even if only ownerId is populated in Phase 1. + +4. **Speaker name persistence.** If a user identifies "Speaker_A" as + "Matt Hill" in one meeting, should that name auto-suggest in the + next meeting if a fingerprint match is found? Requires storing + fingerprints per-user across meetings. Big privacy + product + decision. My instinct: opt-in toggle in user settings, default + off. + +5. **Meeting type defaults.** Should Recaps' submission flow have a + default meeting type, or force the user to pick? My instinct: + default to "default" set; let users pick if they want. + +--- + +## Decision points for Grant + +- Confirm the phasing above (2A → 1 → 2B) is the right order +- Pre-commit to the "uploaded audio is never retained" default — it's + the privacy-safer choice and aligns with how YouTube downloads + already work +- Pick a side on the open questions above before Path 2B starts, OR + defer them as Phase 2/3 of Path 2B diff --git a/docs/per-tenant-subscriptions-plan.md b/docs/per-tenant-subscriptions-plan.md new file mode 100644 index 0000000..b2f189b --- /dev/null +++ b/docs/per-tenant-subscriptions-plan.md @@ -0,0 +1,131 @@ +# Per-Tenant Subscriptions — Implementation Plan + +**Status:** ✅ **ALL STEPS DONE (app 0.2.149, 2026-06-04).** Per-tenant +subscriptions are live: every Pro/Max user gets their own subscriptions + +auto-queue, processed under their own account. The gate is flipped to +tier-based. Offline-verified (99 server tests incl. the ephemeral-session +mechanism + both-mode boot smokes); the actual processing-as-owner run is +the on-device test (see checklist below). + +**Step 4 (the hard part), as built:** the background processor now finds +approved items across every scope (`listAutoQueueScopes`) and processes each +AS its owner — `processItemInternally(item, scope)` mints a short-lived real +session (`mintInternalSession` in auth-routes.js) for the owning user, sends +it as the `recap_session` cookie on the loopback `/api/process` call, and +deletes it on every exit path. No auth bypass — a bad/expired token just +401s and the item is marked failed. Single mode sends no cookie (resolves to +"owner"). `userIdForScope`: single→null; multi "owner"→admin; tenant→the +user id. Bonus: this also fixes the operator's OWN multi-mode auto-processing, +which previously ran the loopback with no identity. + +**Gate flip:** `PRO_FEATURE_GATES` subscriptions gate is now tier-based in +multi mode (Pro/Max/admin pass, free → 402); frontend `canUseSubscriptions() += hasEntitlement("subscriptions")` (operator-only clauses reverted). + +## Landed in 0.2.147 +- `server/subscriptions.js` — scope-keyed storage for subscriptions / skip / + seen / auto-queue + file-locked `mutateAutoQueue` (atomic read-modify-write, + replacing the global in-memory `autoQueue`), `listSubscriptionScopes()`, + `migrateGlobalSubscriptionsToOwner()`, plus the dedup (`getProcessedVideoIds`, + `isKnownVideo`). +- `index.js` — the check loop fans out over `listSubscriptionScopes()` into a + per-scope `checkScopeSubscriptions(scope)`; every endpoint resolves + `scope = subScope(req)` (= scopeForRequest, "owner" for the operator); the + processor + boot recovery use `mutateAutoQueue`; boot runs the migration + + per-scope library reconcile. Behind the gate every scope resolves to + "owner", so behaviour is unchanged for the operator — the plumbing is just + per-scope now. +- `history.js` — `addToSkipList(scope, videoId)` is scope-keyed. + +--- + +## Goal + +Each signed-in Pro/Max tenant manages their **own** channel/podcast +subscriptions; discovered episodes land in **their** auto-queue; approving +one summarizes it under **their** account (their credits, their library, +their relay identity). The operator (admin) keeps theirs. No tenant sees or +affects another's. + +## What already exists (foundation) + +- `server/subscriptions.js` — extracted, unit-tested. `getProcessedVideoIds(scope)` + (scope-aware library scan — the dedup fix) + `isKnownVideo()` (pure + predicate). Already takes a `scope`, so per-tenant dedup is free. +- `history.js` — `scopeForRequest(req)` (→ "owner" for admin/single, else + `safeComponent(user.id)`), `getScopeHistoryDir(scope)`, `scopeDir`, + `renameScopeDir`. The whole per-scope filesystem layer is in place. +- The interim isolation gate: `PRO_FEATURE_GATES[subscriptions].adminOnlyInMulti` + (server) + `canUseSubscriptions()` (frontend). Both get relaxed here. + +## The work + +### 1. Scope the storage (mechanical, testable) +Move the four global files into `subscriptions.js`, each keyed by scope and +rooted at `scopeDir(scope)` instead of the history root: +`subscriptions.json`, `auto-queue.json`, `skip-list.json`, `seen-list.json`. +Add `loadSubscriptions(scope)` / `saveSubscriptions(scope, …)` + the skip / +seen / auto-queue equivalents. **Drop the global in-memory `autoQueue`** — +load/save per scope per request (the in-memory cache is what makes the +current code single-tenant). Unit-test the round-trips per scope. + +### 2. Rescope the endpoints (mechanical) +All ~15 `/api/subscriptions*` + `/api/auto-queue*` handlers derive +`scope = scopeForRequest(req)` and read/write that scope's files. Relax the +gate: drop `adminOnlyInMulti`; gate on the `subscriptions` entitlement +(tier) instead, so paid tenants get in. Update `canUseSubscriptions()` to +`hasEntitlement("subscriptions")` (drop the `!isMulti||isAdmin` clause) and +revert the operator-only frontend branches. + +### 3. Rescope the check loop (moderate) +`_checkSubscriptionsInner()` becomes per-scope. Enumerate scopes that have a +subscriptions file (readdir `history/`, keep subdirs whose `subscriptions.json` +is non-empty; plus "owner"). For each: load that scope's subs, dedup via +`getProcessedVideoIds(scope)` + the scope's skip/seen/queue, append to that +scope's auto-queue. The boot + hourly timers iterate all scopes. + +### 4. Fix the processor identity (the risky bit — needs on-device test) +`backgroundProcessor()` + `processItemInternally(item)` must process each +item **as its owner**. The item already can carry `scope`/`ownerUserId`. + +**Recommended: ephemeral session.** Before the loopback request, mint a +short-lived row in the existing `sessions` table for the owner, send its +token as the `Cookie`, then delete the row in a `finally`. tenant-auth then +resolves `req.user` = owner normally → correct scope, credits, relay +identity. Reuses real auth (no new trust path). **Failure mode is safe:** a +bad/missing session just makes the internal request 401 → the item is +marked `failed`, never an auth hole. Single mode unchanged (no auth, scope +already "owner"). + +*Alternative:* extract the core of the `/api/process` handler into a +function callable in-process with an explicit `{scope, identity}` (no HTTP, +no cookie). Cleaner long-term but a large refactor of a big handler — more +risk of behavioral drift. Prefer the ephemeral session first. + +The single global processor loop stays fine — it just pulls approved items +across all scopes (each item knows its owner) and processes sequentially +with the existing inter-item delay. + +### 5. Migration (one-time, on boot) +Move the existing history-root files +(`subscriptions.json`/`auto-queue.json`/`skip-list.json`/`seen-list.json`) +into `history/owner/` (the admin's scope) once, if present and the target +doesn't exist. Preserves the operator's current subscriptions under their +own scope. Idempotent. + +## On-device test checklist (gates "done") +1. Tenant A subscribes to a channel; tenant B sees **nothing** of A's. +2. A's discovered episode appears only in A's queue; approving it + summarizes under **A** (A's credits decremented, saved to A's library, + A's relay `user:` pool billed). +3. Operator's own subscriptions still discover + auto-process. +4. Per-scope dedup: a video already in A's library is not re-queued for A; + the same video can still be independently queued for B. +5. Crash-recovery: items stuck `processing` resume under the right owner. +6. Single mode unaffected. + +## Effort / risk +Steps 1–3 + 5: ~half a day, low risk, unit-testable offline. Step 4: the +real work — small code, but auth/billing-adjacent, so it carries the +testing burden. Land 1–3 behind the existing operator-only gate first +(no behavior change for tenants), verify, then flip the gate + ship 4. diff --git a/docs/self-serve-purchase-plan.md b/docs/self-serve-purchase-plan.md new file mode 100644 index 0000000..0a3e00c --- /dev/null +++ b/docs/self-serve-purchase-plan.md @@ -0,0 +1,94 @@ +# Self-Serve Pro/Max Purchase — Implementation Plan + +**Status:** Phases 1–4 BUILT + INSTALLED + LIVE on immense-voyage.local +(relay 0.2.121, app 0.2.153) as of 2026-06-08. Bitcoin rail end-to-end test +pending; card rail awaits operator Zaprite config. Phase 5 (SMTP expiry +reminders) remains. Follows the core decoupling (the relay owns tiers, +keyed by user-id) — this lets users buy their own Pro/Max instead of the +operator granting them by hand. + +**Phase 4 (cards via Zaprite) — DONE.** Symmetric with the Bitcoin rail +(one-time prepaid checkout, NOT Zaprite recurring): relay `zaprite-client.js` +(POST /v1/orders + GET /v1/orders/:id), `POST /relay/tier-zaprite-order` +(operator-keyed), `POST /relay/zaprite/webhook` (re-fetch-to-verify — no +signature needed; both rails land at extendUserTier). Config: +`relay_zaprite_{api_key,base_url,currency}` + `relay_tier_prices_fiat_cents_json` +(default $21/$42), set via the new "Set Zaprite Connection" StartOS action. +`/api/billing/buy` gained `method:"card"`; tier-plans reports `card_available` ++ fiat prices; UI shows "Pay by card · $21" only when Zaprite is configured. +Operator TODO: run "Set Zaprite Connection" (paste API key) + register the +webhook at `https:///relay/zaprite/webhook` in Zaprite. + +## Decisions (from Grant) + +- **Prepaid periods, NOT auto-recurring.** A user pays for a fixed period + (default **30 days**) of Pro/Max. Near expiry they get an email reminder + and pay again to extend. At expiry, the tier drops to Core. No stored + payment method, no card-vault / dunning. +- **Two payment rails:** + - **Bitcoin / Lightning via BTCPay** — the *preferred* path. Relay already + has `btcpay-client.js` (createInvoice + webhook HMAC) used for credit + purchases; extend it for tier purchases. + - **Cards via Zaprite** — secondary. Grant has a Zaprite org + API + + webhooks. New integration. +- **UI:** the main pill button is **"Pay with Bitcoin"** (opens a BTCPay + invoice). Directly below it, a smaller **"Pay by card"** link (opens a + Zaprite checkout). +- **Expiry reminders via email.** Set up SMTP (Amazon SES per the Start9 + recommendation) and send an automated "your Pro/Max expires in N days" + email. Recaps already has an SMTP transport (magic-link emails). +- **Prices:** from the relay's `relay_tier_prices_usd_json` (today Pro $5 / + Max $15 per period), USD-denominated, paid in sats for BTCPay. + +## Phases (each shippable on its own) + +### Phase 1 — Relay: prepaid tier model + expiry enforcement (foundation) +Rail-agnostic. Everything else depends on it. +- `setUserTier({userId, tier, periodDays})` → set tier, set + `subscription_expires_at`. **Extend from the current expiry** if the user + is already active (so paying early adds time, doesn't reset it). +- **Enforce expiry:** when the relay resolves a user's tier (identityTier / + the metered-route gate), treat `subscription_expires_at < now` as Core. + Add a lazy check + a periodic sweep so expired users actually drop. +- Keep the operator-grant path (`/relay/user-tier`) working — it's the comp + tool. A manual grant can set no-expiry (operator comp) vs a purchase sets + a dated period. + +### Phase 2 — Bitcoin/Lightning purchase (BTCPay) +- Relay: `POST /relay/tier-invoice` (operator-key authed) — body + `{user_id, tier, period_days}` → `createInvoice` with metadata + `{kind:"tier_subscription", userId, tier, periodDays}` → returns the + checkout URL + invoice id. +- Relay webhook: on a settled `tier_subscription` invoice → `setUserTier` + (extend by periodDays). (Mirrors the existing credit-purchase webhook + branch.) +- Recaps: a `pending_purchases`-style record + the settle→poll→cache-tier + loop (reuse the credit-purchase machinery). On settle, refresh + `/api/license-status` so the badge flips to Pro/Max. + +### Phase 3 — Purchase UI +- Tier picker (Pro / Max, price, "30 days") with the **"Pay with Bitcoin"** + pill + **"Pay by card"** link. Reuse / replace the existing buy modal. +- Bitcoin → opens the BTCPay checkout (Phase 2). Card → opens Zaprite + (Phase 4). + +### Phase 4 — Cards via Zaprite +- Relay (or Recaps): create a Zaprite checkout for the tier (Grant's org + + API), metadata carrying `{userId, tier, periodDays}`. +- Zaprite webhook → verify signature → `setUserTier` (extend). Same landing + point as the BTCPay webhook. +- Wire the "Pay by card" link to it. + +### Phase 5 — Expiry reminder emails +- SMTP via SES (Grant sets up SES + StartOS System SMTP; Recaps' transport + already exists). +- Periodic job: find users with `subscription_expires_at` in ~N days, email + a "renew" notice with a link back to the purchase UI. Idempotent (don't + double-send). + +## Notes / open defaults (sensible unless Grant says otherwise) +- Period = 30 days. Grace = none beyond the advance email (downgrade on + expiry). Extend-from-current-expiry on early renewal. +- Relay stays **`make install` only** (private — never registry-deploy). +- The operator-key path authenticates Recaps→relay for invoice creation, the + same as the tier-grant flow. diff --git a/public/auth.html b/public/auth.html new file mode 100644 index 0000000..cc51205 --- /dev/null +++ b/public/auth.html @@ -0,0 +1,385 @@ + + + + + + Sign in to Recaps + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +

Sign in

+

+ Enter your email — we'll send a sign-in link. +

+ +
+ + + + + + +
+ + + + + + +
+ + + + diff --git a/public/index.html b/public/index.html index bddcdbe..06c106f 100644 --- a/public/index.html +++ b/public/index.html @@ -2,8 +2,44 @@ - - Recap + + + Recaps + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

Sign-in didn't work

+

${safe}

+ +
+ +`; +} + +// sendSignInLink({ email, intent, ip?, userAgent?, emailBody? }) — +// reusable magic-link issuance + email send. Used by: +// • /auth/request-link — visitor-initiated sign-in +// • license-purchase poll-settle — system-initiated post-purchase +// "your account is ready" send +// +// Generates a 32-byte token, hashes it, stores the hash in +// magic_link_tokens, builds a verifyUrl, sends the email with either +// the default magic-link body OR a caller-supplied (subject, text, +// html) tuple for custom flows. Returns { ok: true, expires_at } on +// success; { ok: false, error, message? } on failure. +// +// Doesn't enforce rate limits — that's the caller's job. /auth/request-link +// has the per-email + per-IP buckets; the post-purchase path is +// inherently rate-limited by the actual payment, so no extra bucket +// needed. +export async function sendSignInLink({ + email, + intent = "signin", + ip = null, + userAgent = "", + emailBody = null, + trialCookieId = null, +}) { + if (!email || !isPlausibleEmail(email)) { + return { ok: false, error: "bad_email" }; + } + const publicUrl = await getPublicUrl(); + if (!publicUrl) { + return { ok: false, error: "public_url_not_set" }; + } + if (!isSmtpReady()) { + return { ok: false, error: "smtp_not_ready" }; + } + const now = Date.now(); + const plaintext = randomBytes(32).toString("base64url"); + const tokenHash = sha256(plaintext); + const expiresAt = now + MAGIC_LINK_TTL_MS; + try { + getDb() + .prepare( + `INSERT INTO magic_link_tokens + (token_hash, email, created_at, expires_at, intent, request_ip, request_ua, trial_cookie_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + tokenHash, + email, + now, + expiresAt, + intent, + ip || null, + clipUA(userAgent), + trialCookieId || null, + ); + } catch (err) { + console.error("[auth] sendSignInLink insert failed:", err); + return { ok: false, error: "internal_error" }; + } + const verifyUrl = `${publicUrl}/auth/verify?token=${encodeURIComponent(plaintext)}`; + // Default to the standard sign-in email body; callers can override + // either with a pre-built {subject,text,html} object OR a function + // that receives the verifyUrl and returns that shape. The function + // form is what license-purchase uses to inject the celebratory + // "your Pro account is ready" copy with the verifyUrl pre-rendered + // into the body. + let message; + if (typeof emailBody === "function") { + message = emailBody(verifyUrl); + } else if (emailBody && typeof emailBody === "object") { + message = emailBody; + } else { + message = renderMagicLinkEmail({ + verifyUrl, + brandName: "Recaps", + expiresInMinutes: 15, + }); + } + try { + await sendMail({ + to: email, + subject: message.subject, + text: message.text, + html: message.html, + }); + } catch (err) { + console.error( + "[auth] sendSignInLink sendMail failed for", + email, + ":", + err?.message || err, + ); + // The token is already inserted; if the operator's SMTP is flaky + // the user can re-request a link. Return error so the caller can + // decide whether to surface it or swallow. + return { ok: false, error: "send_failed" }; + } + return { ok: true, expires_at: expiresAt }; +} + +// setupAuthRoutes(app) — registers /auth/* endpoints. Multi-mode only; +// wired in server/index.js behind the RECAP_MODE === 'multi' branch. +// +// Magic-link is the primary auth surface: +// POST /auth/request-link — issue a magic link by email +// GET /auth/verify?token=... — consume the link, issue session +// POST /auth/signout — drop the session +// GET /auth/signout — same (link-click convenience) +// +// Password endpoints are the optional faster-signin add-on: +// POST /auth/set-password — set OR overwrite my password +// POST /auth/clear-password — remove my password (magic-link only) +// POST /auth/signin-password — sign in with email + password +// +// Note there is no /auth/reset-password endpoint by design — reset is +// implemented as "request a magic link, sign in, then call +// /auth/set-password with the new one." Adding a dedicated reset +// endpoint would duplicate the magic-link flow without adding any +// security or UX. +export function setupAuthRoutes(app) { + app.post("/auth/request-link", (req, res) => { + handleRequestLink(req, res).catch((err) => { + console.error("[auth] /auth/request-link unhandled:", err); + res.status(500).json({ error: "internal_error" }); + }); + }); + + app.get("/auth/verify", (req, res) => { + handleVerify(req, res).catch((err) => { + console.error("[auth] /auth/verify unhandled:", err); + res.status(500).send(renderErrorPage("Internal error.")); + }); + }); + + app.post("/auth/signin-password", (req, res) => { + handleSignInPassword(req, res).catch((err) => { + console.error("[auth] /auth/signin-password unhandled:", err); + res.status(500).json({ error: "internal_error" }); + }); + }); + + // Note: /api/account/password (set + clear) is registered by + // account-routes.js, not here — those endpoints REQUIRE an existing + // session, so they live outside the /auth/* public-path namespace + // (which is allowed through the tenant-auth middleware unauthenticated). + + app.post("/auth/signout", (req, res) => { + handleSignout(req, res).catch((err) => { + console.error("[auth] /auth/signout unhandled:", err); + res.status(500).json({ error: "internal_error" }); + }); + }); + + // Convenience GET version for plain link clicks ("Sign out") from + // the UI without needing a form POST. + app.get("/auth/signout", (req, res) => { + handleSignout(req, res).catch((err) => { + console.error("[auth] /auth/signout unhandled:", err); + res.status(500).send(renderErrorPage("Internal error.")); + }); + }); +} diff --git a/server/billing-routes.js b/server/billing-routes.js new file mode 100644 index 0000000..12c58c0 --- /dev/null +++ b/server/billing-routes.js @@ -0,0 +1,252 @@ +// Self-serve subscription purchase (multi-mode / cloud only). +// +// Lets a signed-in cloud user buy their OWN prepaid Pro/Max period +// instead of waiting for the operator to grant it by hand. The relay +// owns the subscription (keyed by Recaps user-id, per the core- +// decoupling); Recaps just brokers the purchase: +// +// POST /api/billing/buy → ask the relay to mint a BTCPay invoice +// for {tier}; return the checkout URL the +// frontend opens. On settlement the relay's +// webhook extends the user's tier. +// GET /api/billing/status → pull the user's current (expiry-enforced) +// tier from the relay and refresh the local +// users.tier cache so the badge flips the +// moment payment lands. The frontend polls +// this after opening checkout. +// +// Auth: both routes require a real signed-in user (req.user.id). Anon / +// trial visitors (req.userId = "anon:") are refused — a tier is +// keyed to a durable user-id, which a trial cookie isn't. +// +// These live under /api/billing (NOT /api/subscriptions — that prefix is +// the channel-subscriptions feature, which is itself Pro-gated; a free +// user must be able to reach the buy flow). The prefix is added to the +// license middleware's open list so the activation gate lets Core users +// through to purchase. + +import { getDb } from "./db.js"; +import { requireUser } from "./tenant-auth.js"; +import { + createRelayTierInvoice, + createRelayZapriteOrder, + getRelayUserTier, + getRelayTierPlans, +} from "./providers/relay.js"; + +const BUYABLE_TIERS = new Set(["pro", "max"]); +const PAYMENT_METHODS = new Set(["bitcoin", "card"]); + +// Fallback prices (sats / 30-day period) used only when the relay is +// unreachable while rendering the picker — matches the relay config +// defaults so the UI never shows a blank price. The actual charge is +// always computed relay-side at invoice time. +const FALLBACK_PLANS = { + period_days: 30, + plans: [ + { tier: "pro", sats: 21000 }, + { tier: "max", sats: 42000 }, + ], +}; + +// Pull the user's effective (expiry-enforced) tier from the relay — the +// authoritative subscription owner — and update the cached users.tier if +// it drifted. Returns { tier, expires_at, synced } or { synced:false } +// when the relay is unreachable / unconfigured (caller falls back to the +// cached value rather than erroring the request). +export async function syncUserTierFromRelay(userId) { + if (!userId) return { synced: false }; + let report; + try { + report = await getRelayUserTier({ userId }); + } catch (err) { + console.warn( + `[billing] relay tier read failed for ${userId}: ${err?.message || err}`, + ); + return { synced: false }; + } + // getRelayUserTier swallows errors and returns null when the relay + // base URL / operator key isn't configured. Treat that as "can't + // sync" rather than "downgrade to core". + if (!report || typeof report.tier !== "string") { + return { synced: false }; + } + const tier = report.tier; // already expiry-enforced by the relay + const expiresAt = report.subscription_expires_at || null; + try { + const db = getDb(); + const row = db.prepare("SELECT tier FROM users WHERE id = ?").get(userId); + if (row && row.tier !== tier) { + db.prepare("UPDATE users SET tier = ? WHERE id = ?").run(tier, userId); + console.log( + `[billing] synced ${userId} tier ${row.tier || "core"} → ${tier} from relay`, + ); + } + } catch (err) { + console.warn( + `[billing] tier cache update failed for ${userId}: ${err?.message || err}`, + ); + } + return { + tier, + expires_at: expiresAt, + tier_snapshot: report.tier_snapshot || tier, + subscription_expired: !!report.subscription_expired, + synced: true, + }; +} + +// Build the buyer-facing origin so the BTCPay checkout can redirect back +// to the app after settlement. Honors the reverse-proxy forwarding +// headers StartOS sets in front of the service. +function originFor(req) { + const proto = + (req.headers["x-forwarded-proto"] || "").split(",")[0].trim() || + req.protocol || + "https"; + const host = req.headers["x-forwarded-host"] || req.headers.host || ""; + return host ? `${proto}://${host}` : ""; +} + +export function setupBillingRoutes(app) { + // GET /api/billing/plans → { period_days, plans: [{tier, sats}] } + // Powers the purchase picker. Prices come from the relay (the pricing + // source of truth); falls back to the config defaults if the relay is + // briefly unreachable so the modal still renders. + app.get("/api/billing/plans", requireUser, async (req, res) => { + if (!req.user || !req.user.id) { + return res.status(403).json({ error: "must_be_signed_in" }); + } + try { + const data = await getRelayTierPlans(); + if (data && Array.isArray(data.plans) && data.plans.length) { + return res.json({ + period_days: data.period_days || FALLBACK_PLANS.period_days, + plans: data.plans, + // Whether the card (Zaprite) rail is configured — the UI hides + // the "Pay by card" link when false so it never offers a rail + // that 503s. Bitcoin is always available (the BTCPay rail). + card_available: !!data.card_available, + source: "relay", + }); + } + } catch (err) { + console.warn(`[billing] plans read failed: ${err?.message || err}`); + } + return res.json({ ...FALLBACK_PLANS, card_available: false, source: "fallback" }); + }); + + // POST /api/billing/buy body: { tier: "pro" | "max", method?: "bitcoin" | "card" } + // Bitcoin (default) → BTCPay invoice; card → Zaprite hosted checkout. + // Returns { ok, method, checkout_url, tier, period_days, ... }. + app.post("/api/billing/buy", requireUser, async (req, res) => { + // Must be a real signed-in user — a tier is keyed to a durable + // user-id, not an anon trial cookie. + if (!req.user || !req.user.id) { + return res.status(403).json({ + error: "must_be_signed_in", + message: "Sign in to buy a subscription.", + }); + } + const tier = String(req.body?.tier || "").trim().toLowerCase(); + if (!BUYABLE_TIERS.has(tier)) { + return res.status(400).json({ + error: "bad_tier", + message: 'tier must be "pro" or "max"', + }); + } + const method = String(req.body?.method || "bitcoin").trim().toLowerCase(); + if (!PAYMENT_METHODS.has(method)) { + return res.status(400).json({ + error: "bad_method", + message: 'method must be "bitcoin" or "card"', + }); + } + const origin = originFor(req); + // Land back on the app with a marker the frontend uses to kick an + // immediate status sync (the modal also polls, so this is a courtesy + // for buyers who follow the checkout redirect). + const returnUrl = origin ? `${origin}/?billing=success` : null; + try { + if (method === "card") { + const order = await createRelayZapriteOrder({ + userId: req.user.id, + tier, + returnUrl, + }); + return res.json({ + ok: true, + method: "card", + checkout_url: order.checkout_url || null, + order_id: order.order_id || null, + amount: order.amount ?? null, + currency: order.currency || null, + tier: order.tier || tier, + period_days: order.period_days ?? null, + }); + } + // Bitcoin (default) — BTCPay invoice. + const invoice = await createRelayTierInvoice({ + userId: req.user.id, + tier, + returnUrl, + }); + res.json({ + ok: true, + method: "bitcoin", + checkout_url: invoice.checkout_url || null, + invoice_id: invoice.invoice_id || null, + sats: invoice.sats ?? null, + tier: invoice.tier || tier, + period_days: invoice.period_days ?? null, + // Lightning BOLT11 for the inline QR (no redirect). Null → the app + // falls back to opening the hosted checkout_url. + bolt11: invoice.bolt11 || null, + lightning_payment_link: invoice.lightning_payment_link || null, + lightning_expires_at: invoice.lightning_expires_at || null, + }); + } catch (err) { + const status = err?.status || 502; + console.error( + `[billing] buy failed for ${req.user.id} (${tier}/${method}): ${err?.message || err}`, + ); + // 503 from the relay = that rail isn't configured; surface a hint. + const notConfigured = + status === 503 || /not[_ ]configured/i.test(err?.message || ""); + const rail = method === "card" ? "Card" : "Bitcoin"; + const tool = method === "card" ? "Zaprite" : "BTCPay"; + res.status(notConfigured ? 503 : 502).json({ + error: notConfigured ? "payments_unavailable" : "checkout_failed", + message: notConfigured + ? `${rail} payments aren't set up on this server yet. Ask the operator to configure ${tool}.` + : "Couldn't start the payment. Please try again in a moment.", + }); + } + }); + + // GET /api/billing/status + // Returns { tier, expires_at, synced } — the user's current relay-owned + // tier, with the local cache refreshed as a side effect. + app.get("/api/billing/status", requireUser, async (req, res) => { + if (!req.user || !req.user.id) { + return res.status(403).json({ error: "must_be_signed_in" }); + } + const synced = await syncUserTierFromRelay(req.user.id); + if (synced.synced) { + return res.json({ + tier: synced.tier, + expires_at: synced.expires_at, + tier_snapshot: synced.tier_snapshot, + subscription_expired: synced.subscription_expired, + synced: true, + }); + } + // Relay unreachable / unconfigured — fall back to the cached tier so + // the UI still renders a sane badge instead of erroring. + return res.json({ + tier: req.user.tier || "core", + expires_at: null, + synced: false, + }); + }); +} diff --git a/server/chunked-analyze.js b/server/chunked-analyze.js new file mode 100644 index 0000000..301b9b1 --- /dev/null +++ b/server/chunked-analyze.js @@ -0,0 +1,487 @@ +// Chunked topic-analysis: split a long transcript into overlapping +// time-windowed slices, analyze each slice in parallel, stitch the +// returned sections back into one coherent list. +// +// Why: a single-shot analyze call against a 2-hour transcript spends +// most of its wall-time on prefill (typically 25K+ tokens). Splitting +// into 18-min slices gives the model a much smaller prompt per call, +// and firing the slices concurrently lets the backend (relay/vLLM or +// Gemini) batch them. End-to-end wall-time drops from minutes to +// tens of seconds for long content, with no quality regression as +// long as the slice boundaries are chosen with overlap and the +// stitcher trusts the second slice for the overlap region. +// +// Public entry point: runChunkedAnalysis(). + +import { buildAnalysisPrompt } from "./gemini-helpers.js"; + +// ── Tunables ──────────────────────────────────────────────────────────────── +// Window body: the part of a chunk that "owns" its topic boundaries. +// Overlap: a tail appended to each window so a topic spanning a +// boundary still gets seen in full by at least one window. +// Stride = body. Windows advance by `body` seconds; each window +// covers `body + overlap` seconds of audio. +const WINDOW_BODY_SECONDS = 18 * 60; // 18 min +const WINDOW_OVERLAP_SECONDS = 2 * 60; // 2 min +// Don't chunk below this duration. A single analyze call against +// <25 min is fast on its own and avoids the stitching complexity +// for the common short-content case. +// Exported so the orchestrator can mirror the decision when picking +// whether to coalesce: above this duration the chunker handles +// granularity per-window, so the pre-chunk coalesce is unnecessary +// and would hurt section-boundary precision. +export const CHUNKING_CUTOFF_SECONDS = 25 * 60; // 25 min +// Max concurrent analyze calls in flight. Gemini paid Tier 1 allows +// ~1000 RPM for flash and ~150 RPM for pro — 12 in-flight is well +// under either ceiling and saturates most operator workloads +// without queueing. Operator hardware (vLLM on a single Spark) caps +// out around 8-12 concurrent for our prompt size, so 12 is a +// reasonable cross-backend default. +const DEFAULT_CONCURRENCY = 12; + +// ── Window planning ───────────────────────────────────────────────────────── +// Plans a set of overlapping windows over the entries array. Each +// window has: +// - startIdx, endIdx: inclusive bounds into the entries array +// - bodyStartIdx: index where this window's "body" begins +// (i.e., everything before this index is the +// overlap with the previous window's tail) +// The first window has bodyStartIdx === startIdx. Windows after the +// first have bodyStartIdx > startIdx by ~overlap seconds. +// +// The stitcher uses bodyStartIdx of window N+1 to decide whether a +// section from window N falls in the contested overlap region. +export function planAnalysisWindows(entries, opts = {}) { + const bodySec = opts.bodySeconds ?? WINDOW_BODY_SECONDS; + const overlapSec = opts.overlapSeconds ?? WINDOW_OVERLAP_SECONDS; + const totalSec = (entries[entries.length - 1].offset || 0) + + (entries[entries.length - 1].duration || 0); + const cutoffSec = opts.cutoffSeconds ?? CHUNKING_CUTOFF_SECONDS; + if (totalSec <= cutoffSec) { + return [{ startIdx: 0, endIdx: entries.length - 1, bodyStartIdx: 0 }]; + } + + const windows = []; + let bodyStartSec = 0; + while (bodyStartSec < totalSec) { + // The window's covered span (body + tail overlap): + const windowEndSec = bodyStartSec + bodySec + overlapSec; + // Body start in entry-index space: first entry with offset >= bodyStartSec. + const bodyStartIdx = firstEntryAtOrAfter(entries, bodyStartSec); + // If there are NO entries at or after bodyStartSec, we've consumed + // all entries. Stop the loop. + if (bodyStartIdx >= entries.length) break; + // GAP HANDLING: if the next entry after bodyStartSec is far in + // the future (past this window's body + overlap), there's a gap + // in the transcript timeline. This commonly happens when the + // transcribe step truncated a middle chunk — the timeline has + // valid entries at, e.g., 0-31 min and 90-94 min but nothing in + // between. Without this fix, the old loop would BREAK at the gap + // (because endIdx < bodyStartIdx triggered the "sparse trailing + // window" exit), silently dropping the entries past the gap from + // analysis entirely. Now we jump bodyStartSec forward to the + // next entry's offset (rounded down to a body-stride boundary + // so subsequent window alignment stays sensible) and continue. + const nextEntryOffset = entries[bodyStartIdx].offset || 0; + if (nextEntryOffset >= windowEndSec) { + bodyStartSec = Math.max( + bodyStartSec + bodySec, + Math.floor(nextEntryOffset / bodySec) * bodySec + ); + continue; + } + // Window's entry range: from the start of overlap-with-prior + // (i.e., bodyStartSec - overlapSec, clamped at 0) through windowEndSec. + const overlapWithPriorSec = Math.max(0, bodyStartSec - overlapSec); + const startIdx = firstEntryAtOrAfter(entries, overlapWithPriorSec); + const endIdx = lastEntryBefore(entries, windowEndSec); + if (endIdx < bodyStartIdx) { + // Defensive: shouldn't happen with the gap-handling above, but + // if it does, advance the body cursor rather than break so we + // don't get stuck. + bodyStartSec += bodySec; + continue; + } + windows.push({ startIdx, endIdx, bodyStartIdx }); + // Stop if this window already covers the last entry. + if (endIdx >= entries.length - 1) break; + bodyStartSec += bodySec; + } + return windows; +} + +function firstEntryAtOrAfter(entries, sec) { + // Linear scan; entries are sorted by offset. + for (let i = 0; i < entries.length; i++) { + if ((entries[i].offset || 0) >= sec) return i; + } + return entries.length; +} + +function lastEntryBefore(entries, sec) { + // Largest i s.t. entries[i].offset < sec. + let ans = -1; + for (let i = 0; i < entries.length; i++) { + if ((entries[i].offset || 0) < sec) ans = i; + else break; + } + // If no entry has offset < sec, return -1 → caller treats as empty. + // If the whole array fits, return entries.length - 1. + return ans === -1 ? -1 : ans; +} + +// ── Parallel analyzer ─────────────────────────────────────────────────────── +// Fires N analyze calls concurrently with a bounded in-flight count. +// Each call gets its own slice of entries plus a freshly-built prompt. +// Returns array of { window, ok, sections | error, cost, model }. +// +// Errors are isolated per window — a single-window failure doesn't +// fail the whole batch. The stitcher gets to decide what to do +// about gaps. +async function analyzeWindowsInParallel({ + entries, + windows, + analyzer, + fallbackModels, + concurrency, + onProgress, + onWindowComplete, + signal, + jobId, + // Total audio duration in seconds — passed through to + // buildAnalysisPrompt so the section-count target scales with the + // full video length (not just per-window). Recap-relay does the + // same; matching here keeps segmentation density consistent + // across both pipelines. When omitted, buildAnalysisPrompt falls + // back to deriving from the entries themselves. + totalAudioSec = 0, +}) { + const results = new Array(windows.length); + let next = 0; + let completed = 0; + + async function worker() { + while (true) { + if (signal?.aborted) return; + const my = next++; + if (my >= windows.length) return; + const w = windows[my]; + const windowEntries = entries.slice(w.startIdx, w.endIdx + 1); + const prompt = buildAnalysisPrompt(windowEntries, { totalAudioSec }); + // Try the configured model first, then walk fallbacks. + let lastErr = null; + let result = null; + let usedModel = null; + for (const tryModel of fallbackModels) { + try { + result = await analyzer.analyzeText({ + prompt, + model: tryModel, + onProgress: () => {}, // suppress per-chunk progress noise + signal, + jobId, + }); + usedModel = tryModel; + break; + } catch (err) { + if (signal?.aborted) return; + lastErr = err; + } + } + if (!result) { + results[my] = { window: w, ok: false, error: lastErr }; + completed++; + onProgress?.(`Window ${my + 1}/${windows.length} failed: ${lastErr?.message?.slice(0, 100) || "unknown"}`); + continue; + } + const parsed = safeParseSections(result.text); + if (!parsed) { + results[my] = { window: w, ok: false, error: new Error("invalid JSON") }; + completed++; + onProgress?.(`Window ${my + 1}/${windows.length} returned invalid JSON`); + continue; + } + results[my] = { + window: w, + ok: true, + sections: parsed.sections, + model: usedModel, + cost: result.cost, + }; + completed++; + onProgress?.(`Window ${my + 1}/${windows.length} done (${parsed.sections.length} topics)`); + + // Fire the streaming callback with this window's BODY-OWNED + // sections — the ones the final stitcher will keep from this + // window. Computed deterministically per-window so the UI can + // render incrementally as windows arrive (even out of order), + // without later having to "undo" any displayed sections. + // + // Rule: window N owns sections whose globalStart falls before + // window(N+1).bodyStartIdx. Sections starting at or after the + // next window's body are deferred — window N+1 will produce an + // authoritative version of them with more downstream context. + if (onWindowComplete) { + const nextBody = my + 1 < windows.length + ? windows[my + 1].bodyStartIdx + : Infinity; + const offset = w.startIdx; + const owned = []; + for (const s of parsed.sections) { + const globalStart = offset + (s.startIndex ?? 0); + const globalEnd = offset + (s.endIndex ?? 0); + if (globalStart >= nextBody) continue; + owned.push({ + startIndex: globalStart, + endIndex: globalEnd, + title: s.title, + summary: s.summary, + }); + } + try { + await onWindowComplete({ + windowIdx: my, + totalWindows: windows.length, + ownedSections: owned, + }); + } catch (cbErr) { + // Callback errors must not derail the analyze loop — + // streaming is best-effort and the canonical result still + // ships at the end. + console.warn( + `[chunked-analyze] onWindowComplete callback failed: ${cbErr?.message || cbErr}` + ); + } + } + } + } + + const workers = Array.from({ length: Math.min(concurrency, windows.length) }, worker); + await Promise.all(workers); + return results; +} + +function safeParseSections(text) { + if (!text) return null; + let jsonStr = text.trim(); + const cb = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/); + if (cb) jsonStr = cb[1].trim(); + try { + const parsed = JSON.parse(jsonStr); + if (!parsed || !Array.isArray(parsed.sections)) return null; + return parsed; + } catch { + return null; + } +} + +// ── Stitcher ──────────────────────────────────────────────────────────────── +// Merges per-window section lists into a single ordered list of +// non-overlapping sections referencing entries by their position in +// the FULL (un-chunked) entries array. +// +// The rule: each window N owns sections whose globalStart falls in +// its body (i.e., globalStart < window(N+1).bodyStartIdx). Any +// section starting at or after the next window's body boundary is +// dropped because the next window will have produced a better +// version of that same section with more downstream context. The +// last window has no successor, so all its sections are kept. +// +// After collection, sections are sorted and any residual overlap +// (which shouldn't happen if windows are well-formed but might +// arise from model index errors) is repaired by clamping endIndex +// to the next section's startIndex - 1. +export function stitchAnalysisResults(results) { + const out = []; + for (let i = 0; i < results.length; i++) { + const r = results[i]; + if (!r || !r.ok) continue; + const next = results[i + 1]; + const nextBody = next && next.window + ? next.window.bodyStartIdx + : Infinity; + const offset = r.window.startIdx; + for (const s of r.sections) { + const globalStart = offset + (s.startIndex ?? 0); + const globalEnd = offset + (s.endIndex ?? 0); + // Drop sections that begin in the next window's body — the + // next window's analysis is authoritative for that range. + if (globalStart >= nextBody) continue; + out.push({ + startIndex: globalStart, + endIndex: globalEnd, + title: s.title, + summary: s.summary, + }); + } + } + // Order + repair overlaps (defensive — shouldn't trigger with + // well-behaved model output, but the existing single-shot path + // doesn't either and this matches its robustness). + out.sort((a, b) => a.startIndex - b.startIndex); + for (let i = 0; i < out.length - 1; i++) { + if (out[i].endIndex >= out[i + 1].startIndex) { + out[i].endIndex = out[i + 1].startIndex - 1; + } + } + return out.filter((s) => s.endIndex >= s.startIndex); +} + +// ── Public entry point ────────────────────────────────────────────────────── +// Runs chunked analysis end-to-end. Returns the same envelope shape +// callers expect from a single-shot analyzer.analyzeText() call: +// { +// text: "", // for prompt/result parity +// model: "", +// cost: { total cost across all windows, summed }, +// usage: null, // no aggregate usage +// attempts: { windows: N, failed: K } // diagnostic +// } +// The caller parses .text the same way it parses a single-shot +// response — no changes to the downstream chunk-building code. +// +// Falls back to single-shot if planning produces just one window +// (i.e., content is below the chunking cutoff). If all windows fail, +// throws so the caller's existing fallback (try next model) kicks in. +export async function runChunkedAnalysis({ + entries, + analyzer, + fallbackModels, + concurrency = DEFAULT_CONCURRENCY, + onProgress = () => {}, + onWindowComplete = null, + signal, + jobId, +}) { + const windows = planAnalysisWindows(entries); + if (windows.length === 1) { + // Single-shot path — same as the legacy code does, but routed + // through here so callers have one entry point. Log message + // distinguishes the two reasons we end up here: + // (a) totalSec ≤ cutoff — short content, intentionally not chunked + // (b) entries are too sparse for multi-window planning — the loop + // broke after one window. Surfaces an awkward state that's + // usually a sign of bad upstream data (e.g. transcribe emitted + // bogus far-future timestamps that the sanity-cap dropped). + const lastEntry = entries[entries.length - 1]; + const totalSec = (lastEntry?.offset || 0) + (lastEntry?.duration || 0); + if (totalSec <= CHUNKING_CUTOFF_SECONDS) { + onProgress( + `Content ≤${Math.round(CHUNKING_CUTOFF_SECONDS / 60)} min — running single-shot analysis` + ); + } else { + onProgress( + `Single window planned over ${entries.length} entries (last @ ${Math.round(totalSec / 60)} min) — running single-shot analysis` + ); + } + return await runSingleShot({ + entries, + analyzer, + fallbackModels, + onProgress, + signal, + jobId, + }); + } + onProgress( + `Chunked analysis: ${windows.length} windows of ~18 min each, up to ${concurrency} in parallel` + ); + // Compute total audio duration from the last entry's offset so the + // section-count target (in buildAnalysisPrompt) scales with the + // FULL video length, not just per-window. Matches recap-relay's + // per-video-duration target methodology for consistent segmentation + // density across both pipelines. + const totalAudioSec = entries.length > 0 + ? (entries[entries.length - 1].offset || 0) + (entries[entries.length - 1].duration || 0) + : 0; + const results = await analyzeWindowsInParallel({ + entries, + windows, + analyzer, + fallbackModels, + concurrency, + onProgress, + onWindowComplete, + signal, + jobId, + totalAudioSec, + }); + // If the caller aborted mid-flight, some result slots may be empty. + // Surface cancellation cleanly to the outer pipeline. + if (signal?.aborted) { + const e = new Error("aborted"); + e.name = "AbortError"; + throw e; + } + const completed = results.filter(Boolean); + const failures = completed.filter((r) => !r.ok); + if (completed.length === 0 || failures.length === completed.length) { + throw new Error( + `All ${results.length} analyze windows failed. First error: ${ + failures[0]?.error?.message || "unknown" + }` + ); + } + const stitched = stitchAnalysisResults(results); + // Aggregate model attribution: pick the most-used successful model. + const modelTally = new Map(); + let totalCost = 0; + for (const r of results) { + if (!r.ok) continue; + modelTally.set(r.model, (modelTally.get(r.model) || 0) + 1); + const c = typeof r.cost?.totalCost === "string" + ? parseFloat(r.cost.totalCost) + : r.cost?.totalCost || 0; + if (Number.isFinite(c)) totalCost += c; + } + const dominantModel = [...modelTally.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || null; + onProgress( + `Chunked analysis complete — ${results.length - failures.length}/${results.length} windows succeeded, ${stitched.length} topics` + ); + return { + text: JSON.stringify({ sections: stitched }), + model: dominantModel, + cost: { + totalCost: totalCost.toFixed(6), + totalCostDisplay: totalCost < 0.01 + ? `$${(totalCost * 100).toFixed(3)}¢` + : `$${totalCost.toFixed(4)}`, + }, + usage: null, + attempts: { windows: results.length, failed: failures.length }, + }; +} + +async function runSingleShot({ + entries, + analyzer, + fallbackModels, + onProgress, + signal, + jobId, +}) { + // Single-shot path: the whole transcript IS the "window". Compute + // totalAudioSec from the entries so the section-count target picker + // chooses the right bucket (<30 min → 6 sections, 30-60 → 8, etc.). + const totalAudioSec = entries.length > 0 + ? (entries[entries.length - 1].offset || 0) + (entries[entries.length - 1].duration || 0) + : 0; + const prompt = buildAnalysisPrompt(entries, { totalAudioSec }); + let lastErr = null; + for (const tryModel of fallbackModels) { + try { + const result = await analyzer.analyzeText({ + prompt, + model: tryModel, + onProgress, + signal, + jobId, + }); + return result; + } catch (err) { + if (signal?.aborted) throw err; + lastErr = err; + } + } + throw lastErr || new Error("All analysis models failed"); +} diff --git a/server/config.js b/server/config.js index 9e87f17..0185fc7 100644 --- a/server/config.js +++ b/server/config.js @@ -25,6 +25,14 @@ let startosConfigPath = null; export let serverApiKey = ""; +// Core-decoupling shared "operator key" — read live from the StartOS +// config sidecar the same way serverApiKey is, so the operator can set it +// via the "Set Relay Operator Key" action without a service restart. +// `RECAP_RELAY_OPERATOR_KEY` env pins the value (local dev). Consumed by +// relay-default.js's getRelayOperatorKey(); see that for the semantics. +let envRelayOperatorKey = ""; +export let relayOperatorKey = ""; + // ── Init ──────────────────────────────────────────────────────────────────── // Call once at boot. Sets up paths, reads the initial value, kicks off the // poll loop. Idempotent if you really want to call it twice (the interval @@ -35,13 +43,17 @@ export async function initConfig({ dataDir }) { startosConfigPath = path.join(configDir, "startos-config.json"); envApiKey = process.env.GEMINI_API_KEY || ""; serverApiKey = envApiKey; + envRelayOperatorKey = (process.env.RECAP_RELAY_OPERATOR_KEY || "").trim(); + relayOperatorKey = envRelayOperatorKey; await fs.mkdir(configDir, { recursive: true }).catch(() => {}); await refreshServerApiKey("startup"); + await refreshRelayOperatorKey("startup"); const pollMs = parseInt(process.env.RECAP_CONFIG_POLL_MS || "3000", 10); setInterval(() => { refreshServerApiKey("config poll").catch(() => {}); + refreshRelayOperatorKey("config poll").catch(() => {}); }, pollMs); } @@ -75,6 +87,27 @@ async function refreshServerApiKey(reason) { } } +async function readRelayOperatorKeyFromConfig() { + try { + const content = await fs.readFile(startosConfigPath, "utf-8"); + const config = JSON.parse(content); + return (config.recap_relay_operator_key || "").trim(); + } catch { + return ""; + } +} + +async function refreshRelayOperatorKey(reason) { + if (envRelayOperatorKey) return; // env var pins the value + const next = await readRelayOperatorKeyFromConfig(); + if (next !== relayOperatorKey) { + relayOperatorKey = next; + console.log( + `[config] relay operator key ${next ? "loaded" : "cleared"} (${reason})`, + ); + } +} + // ── Public helpers ────────────────────────────────────────────────────────── // Resolves the per-request key — either the client's own (BYO) or the // server's stored key (when the client signals USE_SERVER_KEY or sends @@ -132,4 +165,7 @@ export async function mergeConfig(patch) { if (Object.prototype.hasOwnProperty.call(patch, "gemini_api_key")) { await refreshServerApiKey("merge config"); } + if (Object.prototype.hasOwnProperty.call(patch, "recap_relay_operator_key")) { + await refreshRelayOperatorKey("merge config"); + } } diff --git a/server/credits-purchase.js b/server/credits-purchase.js new file mode 100644 index 0000000..a8232a6 --- /dev/null +++ b/server/credits-purchase.js @@ -0,0 +1,671 @@ +// Recap-side proxy to the relay's credit-purchase endpoints. +// +// Architecture is identical to license-purchase.js: Recap doesn't +// hold the BTCPay credentials, the relay does. Recap just forwards +// the buyer's pick to the relay and proxies the polling. The relay +// returns the BTCPay checkout URL which the Recap UI displays in a +// modal styled to match the license-purchase modal. +// +// Endpoints: +// GET /api/credits/packages → relay GET /relay/credits/packages +// POST /api/credits/buy → relay POST /relay/credits/buy +// GET /api/credits/invoice/:id → relay GET /relay/credits/invoice/:id +// +// Auth headers (X-Recap-Install-Id + Authorization Bearer LIC1-...) +// are added by this proxy, not by the buyer-side JS — keeping the +// install identity + license key out of any client-side code. + +import { getRelayBaseURL } from "./relay-default.js"; +import { getInstallId } from "./install-id.js"; +import { getRawLicenseKey } from "./license.js"; + +// Multi-mode toggle. In multi mode every credit purchase is recorded +// in pending_purchases so we know WHO (signed-in user vs. anon trial +// cookie) to credit locally when the invoice settles. Single mode is +// the legacy "operator-pool only" flow — no local accounting layer, +// the relay's credits.json IS the source of truth. +const RECAP_MODE = process.env.RECAP_MODE === "multi" ? "multi" : "single"; + +function relayHeaders({ json = false, req = null } = {}) { + const h = {}; + // Identity routing for the credit-purchase + credit-poll flow: + // + // Pro/Max signed-in tenant (req.user.keysat_license set) + // → use THEIR install ID + license. The buy invoice gets + // stashed with THEIR license_fingerprint so the BTCPay + // settle-webhook credits THEIR license-keyed pool — the + // same pool /api/relay/status reads when displaying their + // balance. Without this, credits land on the operator's + // pool and the buyer sees their balance unchanged after + // paying (the bug Grant hit on 2026-05-18). + // + // Anon visitor (trial cookie only) / free signed-in tenant + // (no license) / single-mode operator + // → fall back to operator identity. The operator's pool is + // what's being topped up; Recaps' own accounting layer + // (anon_trials / tenant_credits) handles the per-user + // attribution locally via pending_purchases. + let installId = null; + let licenseKey = null; + if (req?.user?.keysat_license && req.user.synthetic_install_id) { + installId = req.user.synthetic_install_id; + licenseKey = req.user.keysat_license; + } + if (!installId) { + try { + const id = getInstallId(); + if (id) installId = id; + } catch {} + } + if (!licenseKey) { + try { + const key = getRawLicenseKey(); + if (key) licenseKey = key; + } catch {} + } + if (installId) h["X-Recap-Install-Id"] = installId; + if (licenseKey) h["Authorization"] = `Bearer ${licenseKey}`; + if (json) h["Content-Type"] = "application/json"; + return h; +} + +export function setupCreditsPurchaseRoutes(app) { + // List bundles the operator has configured. Cheap, no auth gating — + // the buyer needs the price menu before they decide whether to pay. + app.get("/api/credits/packages", async (_req, res) => { + const base = getRelayBaseURL(); + if (!base) { + return res.status(503).json({ + error: "relay_not_configured", + message: "Relay base URL not set on this Recaps install.", + }); + } + try { + // 10s timeout — was 5s, but a cold relay request from mobile + // cellular can take 6-8s, and Safari iOS surfaces the abort as + // a generic "Load failed" with no other info, so the buyer + // sees an error and has to manually retry. 10s is still snappy + // enough that a legit failure doesn't hang the UI for long. + const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/packages`, { + signal: AbortSignal.timeout(10000), + }); + const text = await r.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch {} + if (!r.ok) { + return res.status(r.status).json(body || { error: "relay_packages_failed" }); + } + res.json(body || { packages: [] }); + } catch (err) { + console.error(`[credits/packages] failed: ${err?.message || err}`); + res.status(502).json({ + error: "packages_fetch_failed", + message: (err?.message || String(err)).slice(0, 300), + }); + } + }); + + // Initiate a purchase. Body: { credits: 1|5|10|20 }. Returns the + // raw relay envelope (so the UI sees credits_remaining + tier + + // result.checkout_url + result.invoice_id). + // + // Multi-mode: identifies the buyer (signed-in user or anon trial + // cookie), records a pending_purchases row keyed by the invoice_id + // the relay returns. The settle-handler (in /api/credits/invoice/:id + // below) uses that row to know WHERE to apply the credits locally. + // + // Single-mode: skips the pending_purchases bookkeeping entirely; + // the operator IS the buyer and the relay's credits.json directly + // tracks their pool. + app.post("/api/credits/buy", async (req, res) => { + const base = getRelayBaseURL(); + if (!base) { + return res + .status(503) + .json({ error: "relay_not_configured" }); + } + const credits = Number(req.body?.credits); + const returnUrl = + typeof req.body?.return_url === "string" && req.body.return_url.startsWith("http") + ? req.body.return_url + : null; + if (!Number.isFinite(credits) || credits <= 0) { + return res + .status(400) + .json({ error: "credits_required" }); + } + + // Identify the buyer for multi-mode. Either a signed-in user OR + // an anon trial cookie. If neither, attempt to auto-mint a trial + // cookie — anon visitors who click "Buy more" from the toolbar + // (before they've spent their pre-trial allowance) shouldn't be + // dead-ended into a sign-in nag. Same auto-mint pattern as + // /api/process for pre-trial visitors. Only refuse if trials are + // disabled or the IP is over its lifetime cap. + let buyerType = null; + let buyerId = null; + if (RECAP_MODE === "multi") { + if (req.user && req.user.id) { + buyerType = "user"; + buyerId = req.user.id; + } else if (req.trial && req.trial.cookie_id) { + buyerType = "anon"; + buyerId = req.trial.cookie_id; + } else { + // Try to mint a fresh trial cookie so the purchase has + // somewhere to land. forceMint=true bypasses the lifetime + // IP cap and the trials-disabled config — a paying buyer is + // by definition not abusing a free quota, and without a + // tracking cookie the settle handler has nowhere to credit + // the purchase locally (the relay still credits the operator + // pool; we just lose the visibility to apply it to this + // specific buyer). + try { + const { issueIfEligible } = await import("./anon-trial.js"); + const trial = await issueIfEligible({ req, res, forceMint: true }); + if (trial) { + buyerType = "anon"; + buyerId = trial.cookie_id; + // Stash on req for downstream code paths + req.trial = trial; + } + } catch (err) { + console.warn( + "[credits/buy] anon-trial mint failed:", + err?.message || err, + ); + } + if (!buyerId) { + return res.status(401).json({ + error: "buyer_unknown", + message: + "Couldn't create a buyer record for this purchase. Sign up for a free account so we have somewhere to credit it.", + }); + } + } + } + + try { + const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/buy`, { + method: "POST", + headers: relayHeaders({ json: true, req }), + body: JSON.stringify({ credits, return_url: returnUrl || undefined }), + signal: AbortSignal.timeout(15_000), + }); + const text = await r.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch {} + if (!r.ok) { + return res + .status(r.status) + .json(body || { error: "relay_buy_failed" }); + } + + // Record the pending purchase BEFORE we respond, so even if the + // browser refreshes / crashes between buy + settle, the next + // poll for this invoice id will still know who to credit. + // Invoice id lives under result.invoice_id per the relay's + // envelope contract (same shape license-purchase uses). + const invoiceId = + body?.result?.invoice_id || + body?.invoice_id || + body?.btcpay_invoice_id || + null; + if (RECAP_MODE === "multi") { + if (!invoiceId) { + // Loud warning — without an invoice id we can't reconcile + // on settle. Surface the response shape so we can see what + // the relay actually returned and fix the field-name + // assumption if this fires. + console.warn( + `[credits/buy] NO invoice_id in relay response — skipping pending_purchases. Top-level keys: ${Object.keys(body || {}).join(", ")} | result keys: ${Object.keys(body?.result || {}).join(", ")}`, + ); + } else if (!buyerType || !buyerId) { + console.warn( + `[credits/buy] invoice ${invoiceId}: buyer identity missing — won't auto-apply on settle.`, + ); + } else { + try { + const { getDb } = await import("./db.js"); + const result = getDb() + .prepare( + `INSERT OR IGNORE INTO pending_purchases + (invoice_id, buyer_type, buyer_id, credits, created_at) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(invoiceId, buyerType, buyerId, credits, Date.now()); + console.log( + `[credits/buy] tracked pending purchase invoice=${invoiceId} buyer=${buyerType}:${buyerId} credits=${credits} rowsInserted=${result.changes}`, + ); + } catch (err) { + console.error( + `[credits/buy] failed to record pending purchase ${invoiceId}: ${err?.message || err}`, + ); + } + } + } + + res.json(body || {}); + } catch (err) { + console.error(`[credits/buy] failed: ${err?.message || err}`); + res.status(502).json({ + error: "purchase_failed", + message: (err?.message || String(err)).slice(0, 300), + }); + } + }); + + // Poll an invoice's status. Returns the relay envelope; the UI + // reads `result.status` ("new" | "processing" | "settled" | + // "expired" | "invalid") and refreshes when settled. + // + // Multi-mode side effect: when the relay reports settled, we look + // up the matching pending_purchases row and apply the credits to + // the right local balance. Idempotent via applied_at — if the same + // invoice is polled multiple times after settle, only the first + // application takes effect. + app.get("/api/credits/invoice/:id", async (req, res) => { + const base = getRelayBaseURL(); + if (!base) { + return res.status(503).json({ error: "relay_not_configured" }); + } + const id = (req.params.id || "").trim(); + if (!id) { + return res.status(400).json({ error: "missing_invoice_id" }); + } + try { + const r = await fetch( + `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(id)}`, + { + headers: relayHeaders({ req }), + signal: AbortSignal.timeout(10_000), + } + ); + const text = await r.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch {} + if (!r.ok) { + return res + .status(r.status) + .json(body || { error: "relay_poll_failed" }); + } + + // Multi-mode: settle-and-apply. Status path mirrors the + // license-purchase poll-settle handler. + const status = + body?.result?.status || body?.status || null; + if (RECAP_MODE === "multi" && status === "settled") { + try { + await applyPendingPurchase(id); + } catch (err) { + console.error( + `[credits/invoice] apply failed for ${id}: ${err?.message || err}`, + ); + // Don't fail the response — the relay reported settled and + // the operator pool has the credits. Local apply can be + // retried by hitting this endpoint again, or by a future + // reconciliation tool. + } + } + + res.json(body || {}); + } catch (err) { + console.error(`[credits/invoice] failed: ${err?.message || err}`); + res.status(502).json({ + error: "poll_failed", + message: (err?.message || String(err)).slice(0, 300), + }); + } + }); + + // POST /api/credits/claim { invoice_id } + // Manual self-service recovery: a signed-in user pastes the BTCPay + // invoice ID of a purchase they made anonymously (e.g., Safari + // Private mode where the trial cookie didn't survive the magic- + // link click). We verify the invoice is settled at the relay AND + // the pending_purchases row is anon-buyer + unapplied, then credit + // their account. + // + // Safety: + // - Requires authenticated user (req.user.id must be set) + // - Only claims buyer_type='anon' rows (no user-to-user takeover) + // - applied_at idempotency guard prevents double-credit + // - BTCPay invoice IDs are 30+ char random — not enumerable + // - User-buyer rows are never claimable here, regardless of + // ownership — those are the cookie sweep's job + app.post("/api/credits/claim", async (req, res) => { + if (RECAP_MODE !== "multi") { + return res.status(404).json({ error: "not_available" }); + } + if (!req.user || !req.user.id) { + return res.status(401).json({ + error: "auth_required", + message: "Sign in first to claim a purchase to your account.", + }); + } + const invoiceId = String(req.body?.invoice_id || "").trim(); + if (!invoiceId) { + return res.status(400).json({ + error: "missing_invoice_id", + message: "Paste the invoice ID from your purchase email.", + }); + } + + const { getDb } = await import("./db.js"); + const db = getDb(); + const row = db + .prepare( + `SELECT invoice_id, buyer_type, buyer_id, credits, applied_at + FROM pending_purchases WHERE invoice_id = ?`, + ) + .get(invoiceId); + + if (!row) { + return res.status(404).json({ + error: "invoice_not_found", + message: + "We don't have a record of that invoice ID. Double-check it — the ID is shown in your BTCPay payment confirmation.", + }); + } + if (row.buyer_type !== "anon") { + // user-buyer rows are claimable only by their original buyer + // (cookie sweep) — refusing this avoids user-to-user takeover. + return res.status(403).json({ + error: "not_anon_purchase", + message: + "This invoice was bought from a signed-in account and can only be claimed by that account.", + }); + } + if (row.applied_at) { + return res.status(409).json({ + error: "already_applied", + message: + "Those credits were already applied. Check your balance — they may have transferred automatically.", + }); + } + + // Verify settled at the relay before crediting. We do NOT trust + // the local row alone — the buyer could have initiated the + // invoice and never paid; without this check, anyone could + // claim N credits just by knowing an invoice ID. + const base = getRelayBaseURL(); + if (!base) { + return res.status(503).json({ error: "relay_not_configured" }); + } + let status = null; + try { + const r = await fetch( + `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`, + { headers: relayHeaders({ req }), signal: AbortSignal.timeout(10_000) }, + ); + if (r.ok) { + const body = await r.json().catch(() => ({})); + status = body?.result?.status || body?.status || null; + } + } catch (err) { + console.warn( + `[credits/claim] relay status check failed for ${invoiceId}: ${err?.message || err}`, + ); + return res.status(502).json({ + error: "relay_unreachable", + message: + "Couldn't verify the invoice with the payment server. Try again in a minute.", + }); + } + if (status !== "settled") { + return res.status(409).json({ + error: "not_settled", + message: `That invoice is not settled (status: ${status || "unknown"}). If you just paid, wait a minute and try again.`, + }); + } + + try { + await applyPendingPurchase(invoiceId, { forceUserId: req.user.id }); + } catch (err) { + console.error( + `[credits/claim] apply failed for ${invoiceId}: ${err?.message || err}`, + ); + return res.status(500).json({ + error: "apply_failed", + message: "Something went wrong applying the credits. Try again.", + }); + } + console.log( + `[credits/claim] user ${req.user.id} claimed invoice ${invoiceId} (${row.credits} credits)`, + ); + res.json({ ok: true, credits: row.credits }); + }); +} + +// applyPendingPurchase(invoiceId, opts?) — credit the buyer's local +// balance for a settled invoice. Idempotent: bails if the row is +// already marked applied. If the buyer was an anon trial that has +// since been converted to a real user, credits route to the user +// instead. +// +// opts.forceUserId (optional) — route credits to this user instead +// of the row's recorded buyer. Used by the manual-claim endpoint: +// when a signed-in user pastes a BTCPay invoice ID for an anon +// purchase whose trial cookie was lost (e.g., Safari Private mode +// where the magic-link click landed in a different cookie jar), we +// trust the invoice ID as proof-of-ownership and direct the credits +// to their tenant_credits. +// +// Exported so the sweep helper below — and any future server-side +// flow that wants to reconcile a known-settled invoice — can call it +// without going through the /api/credits/invoice/:id route. +export async function applyPendingPurchase(invoiceId, opts = {}) { + const { getDb } = await import("./db.js"); + const db = getDb(); + const row = db + .prepare( + `SELECT invoice_id, buyer_type, buyer_id, credits, applied_at + FROM pending_purchases WHERE invoice_id = ?`, + ) + .get(invoiceId); + if (!row) { + // Either the buy came from a different Recap instance, or the + // bookkeeping insert in /api/credits/buy failed earlier. Nothing + // to do; the operator pool still has the credits from BTCPay. + // Log so operator can reconcile manually if this fires. + console.warn( + `[credits/invoice] settled invoice ${invoiceId} has NO matching pending_purchases row — local balance NOT auto-applied. The credits ARE in the operator pool at the relay; operator should grant manually to the buyer.`, + ); + return; + } + if (row.applied_at) { + return; // already applied, idempotent no-op + } + + // Resolve buyer → target user_id (for tenant_credits) or trial + // cookie_id (for anon_trials.credits_total). Anon-buyers who have + // since converted to a real user get their credits routed to the + // user's tenant_credits — that's the cleaner outcome and matches + // the "credits transfer on signup" semantics the design promises. + let targetUserId = null; + let targetCookieId = null; + if (opts.forceUserId) { + targetUserId = opts.forceUserId; + } else if (row.buyer_type === "user") { + targetUserId = row.buyer_id; + } else if (row.buyer_type === "anon") { + const trial = db + .prepare( + "SELECT cookie_id, converted_to_user_id FROM anon_trials WHERE cookie_id = ?", + ) + .get(row.buyer_id); + if (trial?.converted_to_user_id) { + targetUserId = trial.converted_to_user_id; + } else { + targetCookieId = row.buyer_id; + } + } + + // Apply + mark applied in one transaction so a crash mid-way + // doesn't leave a half-credited buyer. Purchased credits land in + // the PERMANENT bucket (purchased_balance) so they're not wiped on + // the next replenishment refresh. + const tx = db.transaction(() => { + if (targetUserId) { + const existing = db + .prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?") + .get(targetUserId); + if (existing) { + db.prepare( + `UPDATE tenant_credits + SET purchased_balance = purchased_balance + ?, + lifetime_granted = lifetime_granted + ? + WHERE user_id = ?`, + ).run(row.credits, row.credits, targetUserId); + } else { + db.prepare( + `INSERT INTO tenant_credits + (user_id, purchased_balance, replenish_balance, last_replenish_at, + lifetime_granted, lifetime_consumed) + VALUES (?, ?, 0, ?, ?, 0)`, + ).run(targetUserId, row.credits, Date.now(), row.credits); + } + } else if (targetCookieId) { + // Anon trial: credits go into the trial's credits_total (single + // bucket — anons don't have the purchased/replenish split). + // They'll move to purchased_balance on signup via linkToUser. + db.prepare( + `UPDATE anon_trials + SET credits_total = credits_total + ? + WHERE cookie_id = ?`, + ).run(row.credits, targetCookieId); + } + db.prepare( + "UPDATE pending_purchases SET applied_at = ? WHERE invoice_id = ?", + ).run(Date.now(), invoiceId); + }); + tx(); + console.log( + `[credits/invoice] applied ${row.credits} credits for ${row.buyer_type}:${row.buyer_id} → ${ + targetUserId ? "user " + targetUserId : "anon " + targetCookieId + }`, + ); +} + +// sweepUnappliedPurchases({ buyerType, buyerId, cookieIds }) — catch +// up on settled-but-unapplied purchases for a buyer. +// +// Why this exists: the buy → BTCPay → settle → apply pipeline depends +// on the buyer's browser tab polling /api/credits/invoice/:id after +// BTCPay redirects back. But BTCPay redirects in the SAME tab (the +// poll loop dies before it gets a chance to see "settled"), and even +// when the redirect lands back on Recap the buyer might close it +// before the next poll tick. Result: the relay knows the invoice is +// settled and the operator pool has the credits, but the LOCAL +// pending_purchases row never flips to applied — so the buyer's +// balance stays stale until they manually re-poll, which they have no +// way to do. +// +// Fix: opportunistically sweep on every /api/account/whoami and +// /api/relay/status. Cheap (small bounded query + a few relay HTTP +// calls), idempotent (applyPendingPurchase no-ops on already-applied +// rows), and self-healing. +// +// Also called from anon-trial.js linkToUser BEFORE the transfer, so +// any anon-bought credits that hadn't yet been applied locally are +// rolled into anon_trials.credits_total before we copy them over to +// the new user's tenant_credits. +// +// Scope: only sweeps the buyer's OWN pending rows. cookieIds is an +// optional list of additional anon cookie_ids the caller wants +// swept on this buyer's behalf (used by /whoami for the new-signup +// case where the just-converted cookie may still have unapplied +// purchases). Cap at 5 invoices per sweep + 30-minute lookback so a +// degenerate case can't fan out into hundreds of relay calls per +// request. +export async function sweepUnappliedPurchases({ + buyerType, + buyerId, + cookieIds = [], + req = null, +} = {}) { + if (RECAP_MODE !== "multi") return; + if (!buyerType && (!cookieIds || cookieIds.length === 0)) return; + const base = getRelayBaseURL(); + if (!base) return; // no relay configured, nothing to sweep against + + const { getDb } = await import("./db.js"); + const db = getDb(); + + // 30-minute lookback. Older unapplied purchases probably failed for + // a reason we don't want to keep retrying every page-load (relay + // unreachable, invoice expired, etc.). Operator can reconcile + // manually if they fire. + const since = Date.now() - 30 * 60 * 1000; + + // Build the WHERE clause. Always include the primary buyer; OR in + // any extra cookieIds the caller passed. + const conditions = []; + const params = []; + if (buyerType && buyerId) { + conditions.push("(buyer_type = ? AND buyer_id = ?)"); + params.push(buyerType, buyerId); + } + for (const cid of cookieIds) { + if (typeof cid === "string" && cid) { + conditions.push("(buyer_type = 'anon' AND buyer_id = ?)"); + params.push(cid); + } + } + if (conditions.length === 0) return; + params.push(since); + + let rows = []; + try { + rows = db + .prepare( + `SELECT invoice_id FROM pending_purchases + WHERE (${conditions.join(" OR ")}) + AND applied_at IS NULL + AND created_at >= ? + ORDER BY created_at DESC + LIMIT 5`, + ) + .all(...params); + } catch (err) { + console.warn( + `[credits/sweep] query failed: ${err?.message || err}`, + ); + return; + } + if (rows.length === 0) return; + + for (const { invoice_id: invoiceId } of rows) { + try { + const r = await fetch( + `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`, + { + headers: relayHeaders({ req }), + signal: AbortSignal.timeout(5_000), + }, + ); + if (!r.ok) continue; + const text = await r.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch {} + const status = body?.result?.status || body?.status || null; + if (status === "settled") { + await applyPendingPurchase(invoiceId); + } + } catch (err) { + // Best-effort; swallow per-invoice errors so one bad invoice + // doesn't block the others (or the page-load). + console.warn( + `[credits/sweep] invoice ${invoiceId} check failed: ${err?.message || err}`, + ); + } + } +} diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..9a09835 --- /dev/null +++ b/server/db.js @@ -0,0 +1,400 @@ +// Multi-tenant SQLite store — single source of truth for users, +// sessions, magic-link tokens, subscriptions, tenant credits, and +// the library_meta index over /data/history//*.json files. +// +// Created only when RECAP_MODE === 'multi'. In single mode this module +// is never imported — `getDb()` would crash trying to require +// better-sqlite3 anyway, but the auth-middleware short-circuits before +// reaching it. Keep all SQLite access funneled through `getDb()` so +// single-mode boots don't touch the native binding at all. +// +// Forward-only schema. No migration framework — every release is one +// `db.exec(SCHEMA_SQL)` at boot. New columns get `ALTER TABLE …` +// statements appended below the original CREATEs and guarded with an +// existence check; new tables just go in fresh. Rollback is +// "checkpoint your /data dir before upgrading." + +import path from "path"; + +let dbInstance = null; + +const SCHEMA_SQL = ` +-- ── users ────────────────────────────────────────────────────────────── +-- One row per authenticated end-user. The operator-owner is also a row +-- here (is_admin = 1) so per-user library scoping works uniformly. +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT, + created_at INTEGER NOT NULL, + last_signin_at INTEGER, + synthetic_install_id TEXT NOT NULL UNIQUE, + keysat_license TEXT, + display_name TEXT, + is_admin INTEGER NOT NULL DEFAULT 0, + -- Core-decoupling: the user's subscription tier ("core" | "pro" | "max"). + -- The Recap Relay is the source of truth (keyed by user-id); this is the + -- Recaps-side cache used for feature gating, kept in sync by the operator + -- grant flow (which writes here AND POSTs the relay's /relay/user-tier). + tier TEXT NOT NULL DEFAULT 'core', + -- Captured at first signup for forensic / abuse-investigation use. + -- NOT used for auth decisions — just data for the operator to grep + -- when an abuse pattern shows up in the admin dashboard. + signup_ip TEXT, + signup_user_agent TEXT +); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_signup_ip ON users(signup_ip); + +-- ── sessions ─────────────────────────────────────────────────────────── +-- Server-side session store so we can revoke individual sessions from +-- the dashboard. Cookies carry only the random session id. +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_used_at INTEGER, + user_agent TEXT, + ip_address TEXT +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + +-- ── magic_link_tokens ────────────────────────────────────────────────── +-- Plaintext token only ever exists in the outbound email and the +-- inbound verify URL — what we persist is the SHA-256 hash. Tokens are +-- single-use (used_at NOT NULL = spent) and short-lived (15 min). +CREATE TABLE IF NOT EXISTS magic_link_tokens ( + token_hash TEXT PRIMARY KEY, + email TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used_at INTEGER, + intent TEXT NOT NULL, + -- Request context for abuse investigation. Captured at /auth/request-link + -- time, never used for auth decisions — just for the recent-signups admin + -- view to surface scripted abuse patterns. + request_ip TEXT, + request_ua TEXT, + -- Anon trial cookie that was present at /auth/request-link time. + -- Stored server-side (NOT in the magic-link URL itself — that would + -- leak it to anyone who saw the email) so that at /auth/verify we + -- can link the trial → user even when the magic-link click lands + -- in a different browser / cookie jar than the one that initiated + -- the request (Safari Private mode + email-app in-app browser is + -- the canonical case). Server-side binding means the cookie ID + -- can't be spoofed: an attacker who intercepts the magic link + -- still can't change which trial gets linked. + trial_cookie_id TEXT +); +CREATE INDEX IF NOT EXISTS idx_magic_email ON magic_link_tokens(email); +CREATE INDEX IF NOT EXISTS idx_magic_ip ON magic_link_tokens(request_ip, created_at); + +-- ── subscriptions ────────────────────────────────────────────────────── +-- One row per paid period. Multiple rows accumulate as a user renews. +-- We don't try to model "the active subscription" — joins to MAX(started_at) +-- with status='active' do the job and stay honest about history. +CREATE TABLE IF NOT EXISTS subscriptions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tier TEXT NOT NULL, + started_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + cancelled_at INTEGER, + btcpay_invoice_id TEXT, + amount_sats INTEGER, + status TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_subs_user ON subscriptions(user_id); + +-- ── tenant_credits ───────────────────────────────────────────────────── +-- Per-tenant local credit ledger. Cloud users with their OWN keysat +-- license bill the relay directly (via the license-keyed pool); this +-- table is the source of truth for everyone else — signed-in users on +-- the free / cloud-default tier, and family-share tenants on a self- +-- hosted multi-tenant Recap. +-- +-- Two buckets per user: +-- purchased_balance — a la carte purchases + admin grants + carry-over +-- from anon trial conversions. PERMANENT — never +-- wiped or refilled. +-- replenish_balance — initial signup allowance + periodic refills. +-- REFILLED to tenant_default_credits on each +-- anniversary period boundary (period set via +-- the tenant_credit_replenish_period config). +-- Leftover replenish credits at the end of a +-- period are FORFEIT (use-it-or-lose-it). +-- +-- Spend order: debit replenish_balance first (it'll refresh anyway), +-- then purchased_balance only when the refillable bucket is empty. +-- last_replenish_at: epoch-ms of the most recent refill, used to compute +-- the next anniversary boundary. +CREATE TABLE IF NOT EXISTS tenant_credits ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + purchased_balance INTEGER NOT NULL DEFAULT 0, + replenish_balance INTEGER NOT NULL DEFAULT 0, + last_replenish_at INTEGER, + lifetime_granted INTEGER NOT NULL DEFAULT 0, + lifetime_consumed INTEGER NOT NULL DEFAULT 0 +); + +-- ── anon_trials ──────────────────────────────────────────────────────── +-- Cookie-gated "taste before sign-up" trial. The first time an +-- unauthenticated visitor hits /api/process, we issue a recap_anon_trial +-- cookie (32-byte random), insert a row here with N credits (set by +-- the trial_credits_per_visitor operator config), and let them +-- summarize without signing up. After credits_used >= credits_total, +-- the UI nudges them to sign up for more. +-- +-- Trial requests forward the OPERATOR's install_id + license to the +-- relay, so the operator's credit pool is what actually pays for the +-- Gemini call. tenant_credits.balance is irrelevant for trials — +-- the credits_total field on this row is the only gate. +-- +-- ip_address rate-limits trial-cookie issuance: trials_per_ip_per_day +-- caps how many fresh trial cookies one IP can mint in 24h. Doesn't +-- stop sophisticated abuse (IP rotation), but raises the floor for +-- scripted laptop attacks and gives the operator a column to grep on. +-- +-- converted_to_user_id is set when the trial holder signs up — links +-- the trial summary into their library and lets the operator measure +-- the trial → signup conversion rate. +CREATE TABLE IF NOT EXISTS anon_trials ( + cookie_id TEXT PRIMARY KEY, + ip_address TEXT, + user_agent TEXT, + created_at INTEGER NOT NULL, + credits_total INTEGER NOT NULL, + credits_used INTEGER NOT NULL DEFAULT 0, + last_used_at INTEGER, + converted_to_user_id TEXT REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_anon_trials_ip ON anon_trials(ip_address, created_at); +CREATE INDEX IF NOT EXISTS idx_anon_trials_created ON anon_trials(created_at); + +-- ── pending_purchases ────────────────────────────────────────────────── +-- Tracks every credit-purchase invoice initiated through Recap so that +-- when the invoice settles (via BTCPay webhook → relay → poll round- +-- trip back to us) we know WHO to credit locally. +-- +-- The BTCPay invoice on the relay side credits the OPERATOR's pool — +-- the operator paid for the underlying Gemini/etc capacity at the +-- relay. Recap's local accounting layer (tenant_credits for signed-in +-- users, anon_trials.credits_total for trial cookies) is what gates +-- the actual buyer's spend, so we mark this row applied once the +-- relevant local balance is incremented. applied_at being non-null is +-- the idempotency guard — a poll firing twice doesn't double-credit. +-- +-- buyer_type values: +-- "user" → buyer_id is users.id; credits land in tenant_credits +-- "anon" → buyer_id is anon_trials.cookie_id; credits land in +-- anon_trials.credits_total. If the cookie has since been +-- converted to a user (anon_trials.converted_to_user_id), +-- credits route to that user's tenant_credits instead. +CREATE TABLE IF NOT EXISTS pending_purchases ( + invoice_id TEXT PRIMARY KEY, + buyer_type TEXT NOT NULL, + buyer_id TEXT NOT NULL, + credits INTEGER NOT NULL, + created_at INTEGER NOT NULL, + applied_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_pending_purchases_buyer ON pending_purchases(buyer_type, buyer_id); +CREATE INDEX IF NOT EXISTS idx_pending_purchases_unapplied ON pending_purchases(applied_at) WHERE applied_at IS NULL; + +-- ── pending_signups ──────────────────────────────────────────────────── +-- Buyer-creates-account flow: when an anon visitor picks Pro / Max +-- from the tier signup modal, they enter an email and pay BTCPay +-- BEFORE any user account exists. We record the (invoice_id, email, +-- policy_slug) here so the poll-settle handler can create the user + +-- attach the issued license + send a magic-link email once payment +-- lands. applied_at is the idempotency guard — multiple polls after +-- settle don't double-create the user. +-- +-- Distinct from pending_purchases (credit-pack buys) because the +-- settle effects are completely different: pending_signups creates +-- a USER and sends an email; pending_purchases just credits an +-- existing buyer's local balance. +CREATE TABLE IF NOT EXISTS pending_signups ( + invoice_id TEXT PRIMARY KEY, + email TEXT NOT NULL, + policy_slug TEXT NOT NULL, + created_at INTEGER NOT NULL, + applied_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_pending_signups_email ON pending_signups(email); +CREATE INDEX IF NOT EXISTS idx_pending_signups_unapplied ON pending_signups(applied_at) WHERE applied_at IS NULL; + +-- ── subscription_reminders ───────────────────────────────────────────── +-- Dedup ledger for the self-serve expiry-reminder emails. The relay owns +-- the subscription expiry; a daily Recaps scan asks it who's expiring and +-- emails them. This table guarantees each (user, period, kind) email goes +-- out at most once. period_expires_at is the ISO expiry instant the +-- reminder is for — when the user renews, expiry changes, so a fresh set +-- of reminders re-arms for the new period without re-sending old ones. +-- kind is one of 'upcoming_7d', 'upcoming_1d', or 'lapsed'. +CREATE TABLE IF NOT EXISTS subscription_reminders ( + user_id TEXT NOT NULL, + period_expires_at TEXT NOT NULL, + kind TEXT NOT NULL, + sent_at INTEGER NOT NULL, + PRIMARY KEY (user_id, period_expires_at, kind) +); + +-- ── library_meta ─────────────────────────────────────────────────────── +-- Index over /data/history//.json. The summary +-- content stays on disk; this table is just for fast listing without +-- scanning the filesystem. +CREATE TABLE IF NOT EXISTS library_meta ( + session_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + video_id TEXT, + url TEXT, + title TEXT, + type TEXT, + topic_count INTEGER, + segment_count INTEGER, + created_at INTEGER NOT NULL, + upload_date TEXT +); +CREATE INDEX IF NOT EXISTS idx_library_user ON library_meta(user_id, created_at DESC); +`; + +// initDb({ dataDir }) +// Idempotent. Opens /data/recap.db, applies the schema, returns the +// connection. Safe to call multiple times — repeat calls return the +// existing handle. +export async function initDb({ dataDir }) { + if (dbInstance) return dbInstance; + + // Lazy import so single-mode never loads the native binding. + const { default: Database } = await import("better-sqlite3"); + + const dbPath = path.join(dataDir, "recap.db"); + const db = new Database(dbPath); + + // WAL mode for the obvious reasons: concurrent reads while a write + // is in flight, and durable enough for our small write volume + // (signups, sessions, library inserts). `synchronous = NORMAL` is + // the standard pairing — fsync on checkpoint, not every commit. + db.pragma("journal_mode = WAL"); + db.pragma("synchronous = NORMAL"); + db.pragma("foreign_keys = ON"); + + db.exec(SCHEMA_SQL); + + // ── In-place schema migrations ────────────────────────────────────── + // SCHEMA_SQL above is the FRESH-INSTALL schema. Existing installs + // may have an older shape (e.g. tenant_credits with the legacy + // `balance` column). We bring them up to current by introspecting + // PRAGMA table_info and ALTER-ing only where needed. Each migration + // is idempotent — running boot multiple times is safe. + migrateTenantCreditsSchema(db); + migrateMagicLinkTokensTrialCookie(db); + migrateUsersTier(db); + + dbInstance = db; + console.log(`[db] opened ${dbPath} (multi-tenant store)`); + return db; +} + +// Core-decoupling — add users.tier to existing DBs (fresh installs get it +// from SCHEMA_SQL). Idempotent: ALTERs only when the column is missing. +function migrateUsersTier(db) { + let cols; + try { + cols = db.prepare("PRAGMA table_info(users)").all(); + } catch { + return; + } + if (!cols.some((c) => c.name === "tier")) { + db.exec("ALTER TABLE users ADD COLUMN tier TEXT NOT NULL DEFAULT 'core'"); + console.log("[db] added users.tier column (core-decoupling)"); + } +} + +// v0.2.92 — split the single tenant_credits.balance into two buckets +// (purchased + replenish) so we can refill the latter periodically +// without wiping the former. +function migrateTenantCreditsSchema(db) { + let cols; + try { + cols = db.prepare("PRAGMA table_info(tenant_credits)").all(); + } catch { + return; // table doesn't exist yet (shouldn't happen post-SCHEMA_SQL) + } + const colNames = new Set(cols.map((c) => c.name)); + + // 1. Rename legacy `balance` → `purchased_balance`. Existing balances + // were a mix of signup-grant + admin-grant + purchase; treating + // them all as "purchased" (permanent) is the safe interpretation + // — we'd rather over-preserve than wipe credits on upgrade. + if (colNames.has("balance") && !colNames.has("purchased_balance")) { + db.exec( + "ALTER TABLE tenant_credits RENAME COLUMN balance TO purchased_balance", + ); + console.log( + "[db] migrated tenant_credits.balance → tenant_credits.purchased_balance", + ); + colNames.delete("balance"); + colNames.add("purchased_balance"); + } + + if (!colNames.has("replenish_balance")) { + db.exec( + "ALTER TABLE tenant_credits ADD COLUMN replenish_balance INTEGER NOT NULL DEFAULT 0", + ); + console.log("[db] added tenant_credits.replenish_balance"); + } + if (!colNames.has("last_replenish_at")) { + db.exec( + "ALTER TABLE tenant_credits ADD COLUMN last_replenish_at INTEGER", + ); + console.log("[db] added tenant_credits.last_replenish_at"); + } +} + +// v0.2.104 — add trial_cookie_id to magic_link_tokens so cross-cookie- +// jar magic-link clicks (Safari Private → Gmail webview, etc.) still +// link the anon trial to the new user at /auth/verify time. Existing +// installs get the column added in-place; pre-existing rows just keep +// trial_cookie_id = NULL (no linking via the new path, falls back to +// the legacy req.cookies path). +function migrateMagicLinkTokensTrialCookie(db) { + let cols; + try { + cols = db.prepare("PRAGMA table_info(magic_link_tokens)").all(); + } catch { + return; + } + const colNames = new Set(cols.map((c) => c.name)); + if (!colNames.has("trial_cookie_id")) { + db.exec( + "ALTER TABLE magic_link_tokens ADD COLUMN trial_cookie_id TEXT", + ); + console.log("[db] added magic_link_tokens.trial_cookie_id"); + } +} + + +// Returns the open handle. Throws if initDb hasn't run — that's a +// programming error (some single-mode caller reached a multi-mode +// codepath). Callers in multi-mode should assume the handle exists. +export function getDb() { + if (!dbInstance) { + throw new Error( + "[db] getDb() called before initDb(); check RECAP_MODE wiring", + ); + } + return dbInstance; +} + +// Test/teardown helper. Closes the connection so the next initDb() +// call reopens fresh. Not used in production. +export function closeDb() { + if (dbInstance) { + dbInstance.close(); + dbInstance = null; + } +} diff --git a/server/email-template.js b/server/email-template.js new file mode 100644 index 0000000..8abf586 --- /dev/null +++ b/server/email-template.js @@ -0,0 +1,181 @@ +// Magic-link email body builder. Returns { subject, text, html } for +// nodemailer. Keeps the HTML and text in sync — both carry the same +// verifyUrl and the same expiry copy. +// +// Style is deliberately minimal: one paragraph, one button, no images, +// no fancy CSS. Spam filters like simple emails; users skim them and +// click the link. Anything fancier risks the email landing in spam, +// which is fatal to a magic-link auth flow. + +// renderMagicLinkEmail({ verifyUrl, brandName, expiresInMinutes }) +// → { subject, text, html } +export function renderMagicLinkEmail({ + verifyUrl, + brandName = "Recaps", + expiresInMinutes = 15, +}) { + const subject = `Sign in to ${brandName}`; + + const text = [ + `Sign in to ${brandName} by opening this link:`, + "", + verifyUrl, + "", + `This link expires in ${expiresInMinutes} minutes and can only be used once.`, + "", + `If you didn't request this, you can safely ignore this email — no one else can use this link without access to your inbox.`, + ].join("\n"); + + // Inline-styled HTML. Most email clients strip