openvibers/openvibe.shoes Commits

Shoes: create a watch with the person's own Network token (#3)

OP OpenVibers committed 12 days ago · 277ac881d5 · 9 files, +40 −33

Changes

ddiff --git a/.env.example b/.env.example
iindex ca8aba5..0291cca 100644
---- a/.env.example
++++ b/.env.example
@@@ -57,11 +57,10 @@ SHOES_EBAY_CLIENT_SECRET=
#SHOES_EBAY_CATEGORY_ID=93427
#SHOES_EBAY_LIMIT=50
--# OpenVibe.Watch: a service token with watch.watch.manage, and the person in X-OV-Subject. Without a token a watch
--# is saved on Shoes and marked "not connected" (a plain note, never an error).
++# OpenVibe.Watch: a watch is created with the signed-in person's own Network token. Without one the watch is saved
++# on Shoes and marked "not connected" (a plain note, never an error).
SHOES_WATCH_URL=https://openvibe.watch
SHOES_WATCH_INTERNAL_URL=http://127.0.0.1:4730
--SHOES_WATCH_TOKEN=
#SHOES_WATCH_EVERY_SEC=21600
# OpenVibe.Today: the drops calendar's reminders (the person's own Network token is the Bearer).
ddiff --git a/README.md b/README.md
iindex 82c0c63..1ca96ce 100644
---- a/README.md
++++ b/README.md
@@@ -20,7 +20,7 @@ raffle or a draw, never scrapes Facebook Marketplace, and carries no affiliate l
| **OpenVibe.Deals** | the network's own offer aggregator: its public newest-offer pages and each product's public record (brand). No key. | no |
| **eBay Browse API** | official item summaries, only when a developer key pair is set | `SHOES_EBAY_CLIENT_ID` + `SHOES_EBAY_CLIENT_SECRET` |
| **A brand's or a store's own release page** | the official release calendar (Nike SNKRS by default), parsed by a pure parser that never invents a date | no |
--| **OpenVibe.Watch** | a watch for a person (service token; the person in `X-OV-Subject`) | `SHOES_WATCH_TOKEN` |
++| **OpenVibe.Watch** | a watch for a person, created with their own Network token | the person's session |
| **OpenVibe.Today** | a drop reminder on the person's own agenda (their Network token) | the person's session |
Every offer keeps its store, its currency, the price, the shipping the store stated, the condition, the size and
@@@ -109,8 +109,7 @@ Only names in code and docs; values live in `/etc/openvibe/shoes.env` (see [.env
`SHOES_UPSTREAM_TIMEOUT_MS`, `SHOES_REQUEST_GAP_MS`, `SHOES_DROPS_URL`, `SHOES_DROPS_SOURCE_NAME`, `SHOES_DROP_FEEDS`,
`SHOES_EBAY_CLIENT_ID`, `SHOES_EBAY_CLIENT_SECRET`, `SHOES_EBAY_MARKETPLACE`, `SHOES_EBAY_CATEGORY_ID`,
`SHOES_EBAY_LIMIT`.
--- **Watch:** `SHOES_WATCH_URL`, `SHOES_WATCH_INTERNAL_URL`, `SHOES_WATCH_TOKEN`, `SHOES_WATCH_AUDIENCE`,
-- `SHOES_WATCH_CAPABILITY`, `SHOES_WATCH_EVERY_SEC`.
++- **Watch:** `SHOES_WATCH_URL`, `SHOES_WATCH_INTERNAL_URL`, `SHOES_WATCH_EVERY_SEC`.
- **Today:** `SHOES_TODAY_URL`, `SHOES_TODAY_INTERNAL_URL`.
- **Jobs:** `SHOES_OFFERS_INTERVAL_MS`, `SHOES_DROPS_INTERVAL_MS`, `SHOES_EBAY_INTERVAL_MS`.
@@@ -155,6 +154,6 @@ section says so plainly when no store has cut a price yet.
- No raffle, draw, reservation or checkout is automated, ever; the drops calendar links the official page and
adds a reminder on Today.
- Specs/fit/release/care are shown only from cited sources; with none, the page says "not known yet".
--- eBay is off until the host sets a developer key pair; Watch is off until the host sets a service token (the
-- watch is still saved on Shoes and marked "not connected").
++- eBay is off until the host sets a developer key pair; Watch is called with the signed-in person's own Network
++ token (a session without one still saves the watch on Shoes, marked "not connected").
- No affiliate links, no sponsored order, no Marketplace scraping.
ddiff --git a/STATUS.json b/STATUS.json
iindex 38b08be..b28f0e9 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -21,7 +21,7 @@
"One snapshot per offer per day (the first check wins), the price history and the size-price curve from those snapshots",
"The offers job over OpenVibe.Deals' public API, with the footwear classifier and product brand lookup",
"The drops job over a brand's or store's own release page, plus optional RSS feeds; every release cited and dated",
-- "A price watch at a size and a pasted listing link through OpenVibe.Watch's API (service token + X-OV-Subject)",
++ "A price watch at a size and a pasted listing link through OpenVibe.Watch's API with the person's own Network token",
"A drop reminder on OpenVibe.Today with the person's own token; no automated raffle entries",
"The public API: search with facets, one model with offers/history/curve/facts, the price a Watch polls, drops, the person's watches, source status, OpenAPI 3.1",
"The home via sc.room: hero, proof, biggest price drops today, dropping this week, popular models, three scenes, explore, developers, FAQ, network",
@@@ -30,7 +30,7 @@
],
"notYet": [
"eBay is off until the host sets SHOES_EBAY_CLIENT_ID and SHOES_EBAY_CLIENT_SECRET",
-- "OpenVibe.Watch is off until the host sets SHOES_WATCH_TOKEN; a watch is then saved on Shoes and marked not_connected",
++ "A watch needs the person's own Network token; a session without one saves it on Shoes and marks it not_connected",
"Specs, fit notes and care notes are empty until a cited open source is added to the shoe_facts table; the pages say not known yet",
"The size-price curve is empty until a source states the size; no size is invented",
"No closet, image search or merchant inventory feed (plan v2/v3)",
ddiff --git a/server/config.js b/server/config.js
iindex 6b46fbe..f7c9f2b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -94,14 +94,12 @@ function load(env = process.env) {
},
},
-- // OpenVibe.Watch: the watch service that carries the alerts. Without a service token the watch is saved
-- // here and marked "not connected" — a plain note, never an error.
++ // OpenVibe.Watch: the watch service that carries the alerts. The call is made with the signed-in person's
++ // own Network token; without one the watch is saved here and marked "not connected" (a plain note, never
++ // an error).
watch: {
url: trim(env.SHOES_WATCH_URL || 'https://openvibe.watch'),
internalUrl: trim(env.SHOES_WATCH_INTERNAL_URL || 'http://127.0.0.1:4730'),
-- token: env.SHOES_WATCH_TOKEN || '',
-- audience: env.SHOES_WATCH_AUDIENCE || 'openvibe.watch',
-- capability: env.SHOES_WATCH_CAPABILITY || 'watch.watch.manage',
everySec: Math.max(900, int(env.SHOES_WATCH_EVERY_SEC, 21600)),
},
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 868c8b3..554e27b 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -135,7 +135,7 @@ function createApi(ctx) {
condition: { op: 'lt', value: threshold },
action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
};
-- const out = await upstreams.createWatch(body, { subject: req.principal.subject });
++ const out = await upstreams.createWatch(body, { token: req.principal.token });
const watch = await catalogue.createWatch({
subject: req.principal.subject, kind: 'price', modelId: model.id, slug, size, condition, threshold,
currency: 'USD', remoteId: out.ok && out.watch ? out.watch.id : null,
@@@ -156,7 +156,7 @@ function createApi(ctx) {
condition: { op: 'changed' },
action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
};
-- const out = await upstreams.createWatch(body, { subject: req.principal.subject });
++ const out = await upstreams.createWatch(body, { token: req.principal.token });
const watch = await catalogue.createWatch({
subject: req.principal.subject, kind: 'link', sourceUrl: checkedUrl.url,
remoteId: out.ok && out.watch ? out.watch.id : null,
@@@ -176,7 +176,7 @@ function createApi(ctx) {
deals: config.sources.dealsUrl,
drops: config.sources.dropsUrl,
ebay: upstreams.ebayConfigured() ? 'configured' : 'off (no key pair)',
-- watch: config.watch.token ? 'configured' : 'off (no service token)',
++ watch: 'the person\'s own Network token',
},
});
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 456653d..0ce982b 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -133,7 +133,7 @@ function createPageRoutes(ctx) {
condition: { op: 'lt', value: threshold },
action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
};
-- const out = await upstreams.createWatch(body, { subject: req.viewer.subject });
++ const out = await upstreams.createWatch(body, { token: req.viewer.token });
await catalogue.createWatch({
subject: req.viewer.subject, kind: 'price', modelId: model.id, slug: model.slug, size, condition,
threshold, currency: 'USD', remoteId: out.ok && out.watch ? out.watch.id : null,
@@@ -164,7 +164,7 @@ function createPageRoutes(ctx) {
condition: { op: 'changed' },
action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
};
-- const out = await upstreams.createWatch(body, { subject: req.viewer.subject });
++ const out = await upstreams.createWatch(body, { token: req.viewer.token });
await catalogue.createWatch({
subject: req.viewer.subject, kind: 'link', sourceUrl: checkedUrl.url,
remoteId: out.ok && out.watch ? out.watch.id : null,
ddiff --git a/server/upstreams.js b/server/upstreams.js
iindex 2860349..aa3fbf0 100644
---- a/server/upstreams.js
++++ b/server/upstreams.js
@@@ -7,7 +7,7 @@
* products. No key: these are the same reads any person can make.
* eBay the official Browse API (OAuth2 client credentials) when a key pair is set; off otherwise.
* a release page a brand's or a store's own public release calendar (an official page, read as a page).
-- * OpenVibe.Watch the watch service: a watch for a person (a service token; the person in X-OV-Subject).
++ * OpenVibe.Watch the watch service: a watch for a person (the person's own Network token as a Bearer).
* OpenVibe.Today the drops calendar's reminders (the person's own Network token as a Bearer).
*
* A missing key is never an error: the part that needs it is off, with a plain note.
@@@ -164,20 +164,19 @@ function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console,
// ── OpenVibe.Watch ──────────────────────────────────────
/**
-- * Create a watch for a person in OpenVibe.Watch. The service token carries the capability
-- * watch.watch.manage; the person is named in X-OV-Subject. Without a token the watch is not created there
-- * and the caller says so plainly.
++ * Create a watch for a person in OpenVibe.Watch. A person's own Network token is the Bearer: Watch scopes the
++ * watch to that token's subject, exactly as Today does for a reminder. Without a token the watch is not created
++ * there and the caller says so plainly.
*/
-- async function createWatch(body, { subject }) {
-- if (!config.watch.token) return { ok: false, code: 'shoes.watch_not_configured', detail: 'OpenVibe.Watch is not connected yet (no service token).' };
++ async function createWatch(body, { token } = {}) {
++ if (!token) return { ok: false, code: 'shoes.watch_no_token', detail: 'This session has no Network token, so the watch was not created.' };
const url = `${config.watch.internalUrl.replace(/\/+$/, '')}/api/v1/watches`;
try {
const res = await request(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
-- authorization: `Bearer ${config.watch.token}`,
-- 'x-ov-subject': String(subject || ''),
++ authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
json: true,
ddiff --git a/test/api.test.js b/test/api.test.js
iindex a784e75..5bd3230 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -82,7 +82,8 @@ const { assert, equal } = require('./helpers/check');
});
await check('POST /api/v1/me/watches: a price watch through OpenVibe.Watch', async () => {
-- const res = await t.get('/api/v1/me/watches', { as: user, json: { slug: 'nike-air-max-90', size: '10.5', condition: 'new', threshold: 90 } });
++ const session = t.signIn(user);
++ const res = await t.get('/api/v1/me/watches', { cookie: session, origin: t.config.baseUrl, json: { slug: 'nike-air-max-90', size: '10.5', condition: 'new', threshold: 90 } });
equal(res.status, 201);
const b = res.json();
equal(b.watch.kind, 'price');
@@@ -94,11 +95,21 @@ const { assert, equal } = require('./helpers/check');
assert(sent.body.source.url.includes('/api/v1/shoes/nike-air-max-90/price'), 'the feed URL');
equal(sent.body.condition.op, 'lt');
equal(sent.body.condition.value, 90);
-- equal(sent.headers['x-ov-subject'], user.subject);
-- const list = (await t.get('/api/v1/me/watches', { as: user })).json();
++ equal(sent.headers['x-ov-subject'], undefined, 'no invented subject header');
++ equal(sent.headers.authorization, `Bearer ${session.slice('shoes_at='.length)}`, 'the person\'s own Network token is the Bearer');
++ const list = (await t.get('/api/v1/me/watches', { cookie: session })).json();
equal(list.watches.length, 1);
});
++ await check('a session without a Network token is refused before Watch is called', async () => {
++ const before = t.upstreams.state.watchRequests.length;
++ const out = await t.ctx.upstreams.createWatch({ name: 'no token', source: { kind: 'api', url: 'https://open.shoes/x' } });
++ equal(out.ok, false);
++ equal(out.code, 'shoes.watch_no_token');
++ assert(/Network token/.test(out.detail), 'the answer says why');
++ equal(t.upstreams.state.watchRequests.length, before, 'Watch was not called');
++ });
++
await check('POST /shoe/:slug/watch: the page form creates the same price watch', async () => {
const before = t.upstreams.state.watchRequests.length;
const res = await t.get('/shoe/nike-air-max-90/watch', { as: user, form: { threshold: '75', size: '10.5', condition: 'new' } });
@@@ -108,6 +119,7 @@ const { assert, equal } = require('./helpers/check');
const sent = t.upstreams.state.watchRequests[before];
equal(sent.body.condition.value, 75);
equal(sent.body.source.kind, 'api');
++ equal(sent.headers['x-ov-subject'], undefined, 'the page form sends no subject header either');
});
await check('POST /api/v1/me/track: a pasted link becomes a Watch', async () => {
@@@ -140,7 +152,7 @@ const { assert, equal } = require('./helpers/check');
await check('GET /api/v1/status: the jobs, counts and what is configured', async () => {
const b = (await t.get('/api/v1/status')).json();
equal(b.counts.models, 4);
-- equal(b.sources.watch, 'configured');
++ equal(b.sources.watch, 'the person\'s own Network token');
equal(b.sources.ebay, 'configured');
assert(b.jobs.some((j) => j.job === 'offers'), 'the offers job is listed');
assert(b.jobs.some((j) => j.job === 'drops'), 'the drops job is listed');
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 422a560..1850419 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -40,7 +40,7 @@ async function boot(opts = {}) {
SHOES_DROPS_URL: `${upstreams.url}/launch`,
SHOES_DROPS_SOURCE_NAME: 'Nike SNKRS launch calendar',
SHOES_DROP_FEEDS: JSON.stringify([{ url: `${upstreams.url}/feed`, name: 'Example releases', type: 'rss' }]),
-- SHOES_WATCH_INTERNAL_URL: upstreams.url, SHOES_WATCH_TOKEN: 'watch-test-token',
++ SHOES_WATCH_INTERNAL_URL: upstreams.url,
SHOES_TODAY_INTERNAL_URL: upstreams.url,
SHOES_EBAY_CLIENT_ID: 'ebay-id', SHOES_EBAY_CLIENT_SECRET: 'ebay-secret', SHOES_EBAY_API_URL: upstreams.url,
SHOES_UPSTREAM_TIMEOUT_MS: '4000',