shinten-ship 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -29,7 +29,7 @@ shinten-ship はその誰かの道具です。出荷担当の端末で動き、
29
29
 
30
30
  ```sh
31
31
  npm install -g shinten-ship
32
- shinten keygen > desk.json # shinten の鍵。公開側を店舗のフォームの宛先として公開する
32
+ shinten keygen -o desk.json # shinten の鍵。公開側を店舗のフォームの宛先として公開する
33
33
  ```
34
34
 
35
35
  `ship.json` に差出人と業者のアカウントを書きます。
@@ -38,10 +38,18 @@ shinten keygen > desk.json # shinten の鍵。公開側を店舗のフ
38
38
  {
39
39
  "sender": { "name": "株式会社テスト商店", "postalCode": "100-0001", "address": "東京都千代田区千代田1-1", "building": "テストビル3F", "phone": "03-1234-5678" },
40
40
  "yamato": { "customerCode": "123456789", "fareCode": "01" },
41
- "japanpost": { "mailType": "0" }
41
+ "japanpost": { "mailType": "0" },
42
+ "recipients": { "keys": [ { "kty": "EC", "crv": "P-256", "...": "この発送窓口" }, { "kty": "EC", "crv": "P-256", "...": "控えの鍵" } ] }
42
43
  }
43
44
  ```
44
45
 
46
+ `recipients` は、店舗のフォームが封じるはずの公開鍵を、この発送窓口が承認したものです。
47
+ 他の鍵にも封じられた注文や、これらの鍵が欠けた注文は、店舗のサイトの鍵一覧が
48
+ 差し替えられたことを意味します。ラベルは一つも作られず、`labels` は STOP で終わります。
49
+ 同じ鍵で `shinten watch -r` を定期的に店舗の鍵一覧に向けて実行すれば、注文が来る前に
50
+ 差し替えを見つけられます。
51
+
52
+
45
53
  ヤマトの `customerCode` は B2クラウド契約のご請求先顧客コード、`fareCode` は
46
54
  運賃管理番号です。請求先コードに3桁の分類がある場合は `classCode` を足します。
47
55
 
package/README.md CHANGED
@@ -32,7 +32,7 @@ alone cannot be opened by anyone.
32
32
 
33
33
  ```sh
34
34
  npm install -g shinten-ship
35
- shinten keygen > desk.json # from shinten; publish the public half as a recipient of the store's forms
35
+ shinten keygen -o desk.json # from shinten; publish the public half as a recipient of the store's forms
36
36
  ```
37
37
 
38
38
  `ship.json` holds the sender and the carrier accounts:
@@ -41,10 +41,18 @@ shinten keygen > desk.json # from shinten; publish the public half as a
41
41
  {
42
42
  "sender": { "name": "株式会社テスト商店", "postalCode": "100-0001", "address": "東京都千代田区千代田1-1", "building": "テストビル3F", "phone": "03-1234-5678" },
43
43
  "yamato": { "customerCode": "123456789", "fareCode": "01" },
44
- "japanpost": { "mailType": "0" }
44
+ "japanpost": { "mailType": "0" },
45
+ "recipients": { "keys": [ { "kty": "EC", "crv": "P-256", "...": "this desk" }, { "kty": "EC", "crv": "P-256", "...": "the backup" } ] }
45
46
  }
46
47
  ```
47
48
 
49
+ `recipients` are the public keys the store's forms are meant to seal to, as
50
+ this desk approved them. An order sealed to any other key, or missing one of
51
+ these, means the key list on the store's site was replaced: no labels are
52
+ made, and `labels` exits with STOP. Run `shinten watch -r` on the same keys
53
+ against the store's key list on a schedule, to find a replacement before
54
+ any order.
55
+
48
56
  For Yamato, `customerCode` is the ご請求先顧客コード and `fareCode` the
49
57
  運賃管理番号 from your B2 Cloud contract; add `classCode` when the billing
50
58
  code has a 3-digit class.
package/dist/src/cli.js CHANGED
@@ -52,7 +52,15 @@ async function main(argv) {
52
52
  const config = parseConfig(await readFile(need(values.config ?? process.env.SHINTEN_SHIP_CONFIG, '--config or SHINTEN_SHIP_CONFIG'), 'utf8'));
53
53
  const orders = parseOrders(await readText(positionals[0]));
54
54
  const out = values.out ?? `labels-${c.id}-${new Date().toISOString().slice(0, 10)}.csv`;
55
+ if (!config.recipients)
56
+ console.error('warning: ship.json has no recipients; orders are not checked against the keys this desk approved');
55
57
  const r = await makeLabels({ orders, carrier: c, config, key, recorder: new JsonlRecorder() });
58
+ if (r.alarm) {
59
+ for (const p of r.problems)
60
+ console.error(`order ${p.order}: ${p.problem}`);
61
+ console.error(`STOP: ${r.alarm}`);
62
+ return 3;
63
+ }
56
64
  await writeFile(out, r.file, { mode: 0o600 });
57
65
  for (const p of r.problems)
58
66
  console.error(`order ${p.order}: ${p.problem}`);
@@ -1,6 +1,13 @@
1
- /** The desk's configuration: who the parcels are from, and the carrier accounts. */
1
+ import type { JWK } from 'jose';
2
+ /** The desk's configuration: who the parcels are from, the carrier accounts, and the keys the desk approved. */
2
3
  export interface Config {
3
4
  sender: Sender;
5
+ /**
6
+ * The public keys the store's forms are meant to seal to: this desk's and
7
+ * the backup. An order sealed to any other key, or missing one of these,
8
+ * means the key list on the store's site was replaced; no labels are made.
9
+ */
10
+ recipients?: JWK[];
4
11
  yamato?: {
5
12
  /** ご請求先顧客コード, 9 to 12 digits. Required by B2 Cloud for imported data. */
6
13
  customerCode: string;
@@ -1,5 +1,12 @@
1
1
  export function parseConfig(json) {
2
2
  const c = JSON.parse(json);
3
+ if (c.recipients !== undefined) {
4
+ const list = Array.isArray(c.recipients) ? c.recipients : c.recipients.keys;
5
+ if (!Array.isArray(list) || list.length === 0 || list.some((k) => typeof k !== 'object' || 'd' in k)) {
6
+ throw new Error('config: recipients must be the approved public keys, a JWK Set or a list');
7
+ }
8
+ c.recipients = list;
9
+ }
3
10
  const s = c.sender;
4
11
  if (!s || typeof s.name !== 'string' || typeof s.postalCode !== 'string' || typeof s.address !== 'string' || typeof s.phone !== 'string') {
5
12
  throw new Error('config: sender needs name, postalCode, address and phone');
@@ -19,6 +19,12 @@ export interface Labels {
19
19
  }[];
20
20
  /** Orders left out, and why. */
21
21
  problems: Problem[];
22
+ /**
23
+ * Set when an order was sealed to keys the desk did not approve: the key
24
+ * list on the store's site was replaced. No labels are written; stop and
25
+ * check the site before opening anything more.
26
+ */
27
+ alarm?: string;
22
28
  }
23
29
  export interface MakeLabelsOptions {
24
30
  orders: Order[];
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { open } from 'shinten';
2
+ import { open, RecipientError } from 'shinten';
3
3
  import { encodeCsv, formatCsv } from './csv.js';
4
4
  export function sha256(b) {
5
5
  return createHash('sha256').update(b).digest('hex');
@@ -16,11 +16,12 @@ export async function makeLabels(opts) {
16
16
  const rows = [opts.carrier.header];
17
17
  const written = [];
18
18
  const problems = [];
19
+ const alarmed = [];
19
20
  for (const order of opts.orders) {
20
21
  const envelope = sha256(JSON.stringify(order.envelope));
21
22
  let delivery;
22
23
  try {
23
- const opened = await open({ clear: { postalCode: order.postalCode }, envelope: order.envelope }, opts.key);
24
+ const opened = await open({ clear: { postalCode: order.postalCode }, envelope: order.envelope }, opts.key, opts.config.recipients ? { recipients: opts.config.recipients } : {});
24
25
  if (opened.profile !== 'shipping')
25
26
  throw new Error(`envelope profile is ${opened.profile}, not shipping`);
26
27
  const f = opened.fields;
@@ -28,6 +29,8 @@ export async function makeLabels(opts) {
28
29
  }
29
30
  catch (e) {
30
31
  problems.push({ order: order.id, problem: e.message });
32
+ if (e instanceof RecipientError)
33
+ alarmed.push(order.id);
31
34
  continue;
32
35
  }
33
36
  const r = opts.carrier.row(order, delivery, opts.config, today);
@@ -39,6 +42,13 @@ export async function makeLabels(opts) {
39
42
  rows.push(r.cells);
40
43
  written.push({ id: order.id, envelope });
41
44
  }
45
+ if (alarmed.length) {
46
+ // One order sealed to a stranger's key means every order since the key
47
+ // list changed may have been: make nothing, and say so.
48
+ const alarm = `orders ${alarmed.join(', ')} were sealed to keys this desk did not approve: the key list on the store's site may have been replaced. No labels were made. Check the site's keys (shinten watch) before opening anything more.`;
49
+ const file = encodeCsv(formatCsv([opts.carrier.header]), opts.carrier.encoding);
50
+ return { file, digest: sha256(file), written: [], problems, alarm };
51
+ }
42
52
  const file = encodeCsv(formatCsv(rows), opts.carrier.encoding);
43
53
  const digest = sha256(file);
44
54
  if (opts.recorder && written.length) {
package/dist/src/mcp.js CHANGED
@@ -52,6 +52,8 @@ export function deskServer(opts) {
52
52
  const c = carrier(id);
53
53
  const source = orders.trimStart().startsWith('[') || orders.trimStart().startsWith('{') || orders.includes('\n') ? orders : decodeCsv(await readFile(orders));
54
54
  const r = await makeLabels({ orders: parseOrders(source), carrier: c, config: await config(), key: await key(), recorder });
55
+ if (r.alarm)
56
+ throw new Error(`STOP: ${r.alarm} Tell the person running the desk.`);
55
57
  await writeFile(out, r.file, { mode: 0o600 });
56
58
  const result = { out, digest: r.digest, written: r.written, problems: r.problems, note: c.note };
57
59
  return { ...text(result), structuredContent: result };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shinten-ship",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "The shipping desk for shinten: opens sealed delivery addresses on the desk's own device and writes the carrier's label-import CSV, so the store never holds an address in the clear.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": "github:eucassoc/shinten-ship",
@@ -25,7 +25,7 @@
25
25
  "dependencies": {
26
26
  "@modelcontextprotocol/sdk": "^1.32.2",
27
27
  "iconv-lite": "^0.6.3",
28
- "shinten": "^0.1.0",
28
+ "shinten": "^0.2.0",
29
29
  "zod": "^4.6.7"
30
30
  },
31
31
  "devDependencies": {