All files / src/handlers post-command.ts

100% Statements 21/21
100% Branches 22/22
100% Functions 3/3
100% Lines 17/17

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106                                                                                  3x                                         32x 32x   29x   29x 15x 15x   14x     14x 4x   10x 3x 2x         8x                 14x 11x                 14x    
/**
 * Body classification for POST /_/:room -- command execution.
 *
 * This is the pure counterpart to the handler glue in src/routes/rooms.ts.
 * The HTTP handler:
 *   1. reads the body bytes
 *   2. calls classifyCommandBody(contentType, bytes) -> ClassifiedCommand
 *   3. applies the text-wiki filter, multi-cascade rename, and
 *      loadclipboard enrichment in the glue layer
 *   4. dispatches to POST /_do/commands on RoomDO
 *   5. returns 202 with a JSON body { command }
 *
 * Legacy reference: src/main.ls:321-343 (request-to-command) and
 * src/main.ls:406-446 (the post handler). The legacy server's
 * content-type dispatch:
 *   application/json   -> read body.command (string)
 *   text/x-socialcalc  -> use body literally
 *   text/plain         -> use body literally
 *   else (xlsx/ods)    -> run J lib to produce a loadclipboard command
 *
 * For Phase 6 we DO NOT implement the xlsx -> loadclipboard decoder
 * (that lands in Phase 8 alongside the export side). xlsx/ods POSTs
 * return 501 at the HTTP layer; this function signals that with kind
 * 'xlsx-deferred'.
 */
 
/** The command types returned to the caller. */
export type ClassifiedCommand =
  | { readonly kind: 'empty' }
  | {
      readonly kind: 'json-command';
      /** JSON .command -- may be a string or an array of strings. */
      readonly command: string | readonly string[];
    }
  | {
      readonly kind: 'text-command';
      /** Raw body text (treated as-is; subject to loadclipboard enrichment). */
      readonly command: string;
    }
  | { readonly kind: 'xlsx-deferred' };
 
const XLSX_MIMES: readonly string[] = [
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  'application/vnd.oasis.opendocument.spreadsheet',
];
 
/**
 * Classify the POST body into one of:
 *   - empty         (missing / blank body -- return 400 at the caller)
 *   - json-command  (JSON with a non-empty .command; forward as-is)
 *   - text-command  (plain text / text/x-socialcalc; subject to
 *                    isLoadClipboard / isMultiCascade checks in the glue)
 *   - xlsx-deferred (501)
 *
 * The caller is responsible for applying loadclipboard enrichment,
 * multi-cascade rename, and the text-wiki filter before dispatching
 * to the DO.
 */
export function classifyCommandBody(
  contentType: string,
  bytes: Uint8Array,
): ClassifiedCommand {
  const ct = contentType.split(';')[0]!.trim().toLowerCase();
  if (XLSX_MIMES.includes(ct)) return { kind: 'xlsx-deferred' };
 
  const bodyText = new TextDecoder('utf-8').decode(bytes);
 
  if (ct === 'application/json') {
    try {
      const parsed = JSON.parse(bodyText) as unknown;
      const candidate =
        parsed && typeof parsed === 'object'
          ? (parsed as Record<string, unknown>).command
          : undefined;
      if (typeof candidate === 'string' && candidate.length > 0) {
        return { kind: 'json-command', command: candidate };
      }
      if (Array.isArray(candidate) && candidate.every((c) => typeof c === 'string')) {
        if (candidate.length === 0) return { kind: 'empty' };
        return { kind: 'json-command', command: candidate as readonly string[] };
      }
    } catch {
      /* fall through to empty */
    }
    return { kind: 'empty' };
  }
 
  // text/x-socialcalc, text/plain, or anything else treated literally.
  // Legacy matches request.is('application/json') and request.is('text/x-socialcalc')
  // specifically; unmatched types fall into the J-library xlsx path
  // which we defer. We accept text/plain here to match the legacy
  // fallback for bodies posted without a content-type (browsers default
  // to text/plain for plain strings).
  if (bodyText.length === 0) return { kind: 'empty' };
  return { kind: 'text-command', command: bodyText };
}
 
/**
 * Join an array of commands with newlines for DO dispatch. Mirrors
 * legacy main.ls:439 (cmdstr = command * '\n'). Exported so the test
 * layer can pin the exact wire shape.
 */
export function joinCommands(command: string | readonly string[]): string {
  return Array.isArray(command) ? command.join('\n') : (command as string);
}