openvibers/openvibe.shoes Commits
Shoes: create a watch with the person's own Network token (#3)
Changes
@@@ -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.watchSHOES_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).@@@ -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 andadds 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.@@@ -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)",@@@ -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)),},@@@ -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',},});});@@@ -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,@@@ -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,@@@ -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');@@@ -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',
@@ -57,11 +57,10 @@ SHOES_EBAY_CLIENT_SECRET=
@@ -57,11 +57,10 @@ SHOES_EBAY_CLIENT_SECRET=
#SHOES_EBAY_CATEGORY_ID=93427
#SHOES_EBAY_CATEGORY_ID=93427
#SHOES_EBAY_LIMIT=50
#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_URL=https://openvibe.watch
SHOES_WATCH_INTERNAL_URL=http://127.0.0.1:4730
SHOES_WATCH_INTERNAL_URL=http://127.0.0.1:4730
-SHOES_WATCH_TOKEN=
#SHOES_WATCH_EVERY_SEC=21600
#SHOES_WATCH_EVERY_SEC=21600
# OpenVibe.Today: the drops calendar's reminders (the person's own Network token is the Bearer).
# OpenVibe.Today: the drops calendar's reminders (the person's own Network token is the Bearer).
@@ -20,7 +20,7 @@ raffle or a draw, never scrapes Facebook Marketplace, and carries no affiliate l
@@ -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 |
| **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` |
| **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 |
| **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 |
| **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
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
@@ -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_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_CLIENT_ID`, `SHOES_EBAY_CLIENT_SECRET`, `SHOES_EBAY_MARKETPLACE`, `SHOES_EBAY_CATEGORY_ID`,
`SHOES_EBAY_LIMIT`.
`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`.
- **Today:** `SHOES_TODAY_URL`, `SHOES_TODAY_INTERNAL_URL`.
- **Jobs:** `SHOES_OFFERS_INTERVAL_MS`, `SHOES_DROPS_INTERVAL_MS`, `SHOES_EBAY_INTERVAL_MS`.
- **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.
@@ -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
- No raffle, draw, reservation or checkout is automated, ever; the drops calendar links the official page and
adds a reminder on Today.
adds a reminder on Today.
- Specs/fit/release/care are shown only from cited sources; with none, the page says "not known yet".
- 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.
- No affiliate links, no sponsored order, no Marketplace scraping.
@@ -21,7 +21,7 @@
@@ -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",
"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 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",
"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",
"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 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",
"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 @@
@@ -30,7 +30,7 @@
],
],
"notYet": [
"notYet": [
"eBay is off until the host sets SHOES_EBAY_CLIENT_ID and SHOES_EBAY_CLIENT_SECRET",
"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",
"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",
"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)",
"No closet, image search or merchant inventory feed (plan v2/v3)",
@@ -94,14 +94,12 @@ function load(env = process.env) {
@@ -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: {
watch: {
url: trim(env.SHOES_WATCH_URL || 'https://openvibe.watch'),
url: trim(env.SHOES_WATCH_URL || 'https://openvibe.watch'),
internalUrl: trim(env.SHOES_WATCH_INTERNAL_URL || 'http://127.0.0.1:4730'),
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)),
everySec: Math.max(900, int(env.SHOES_WATCH_EVERY_SEC, 21600)),
},
},
@@ -135,7 +135,7 @@ function createApi(ctx) {
@@ -135,7 +135,7 @@ function createApi(ctx) {
condition: { op: 'lt', value: threshold },
condition: { op: 'lt', value: threshold },
action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
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({
const watch = await catalogue.createWatch({
subject: req.principal.subject, kind: 'price', modelId: model.id, slug, size, condition, threshold,
subject: req.principal.subject, kind: 'price', modelId: model.id, slug, size, condition, threshold,
currency: 'USD', remoteId: out.ok && out.watch ? out.watch.id : null,
currency: 'USD', remoteId: out.ok && out.watch ? out.watch.id : null,
@@ -156,7 +156,7 @@ function createApi(ctx) {
@@ -156,7 +156,7 @@ function createApi(ctx) {
condition: { op: 'changed' },
condition: { op: 'changed' },
action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
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({
const watch = await catalogue.createWatch({
subject: req.principal.subject, kind: 'link', sourceUrl: checkedUrl.url,
subject: req.principal.subject, kind: 'link', sourceUrl: checkedUrl.url,
remoteId: out.ok && out.watch ? out.watch.id : null,
remoteId: out.ok && out.watch ? out.watch.id : null,
@@ -176,7 +176,7 @@ function createApi(ctx) {
@@ -176,7 +176,7 @@ function createApi(ctx) {
deals: config.sources.dealsUrl,
deals: config.sources.dealsUrl,
drops: config.sources.dropsUrl,
drops: config.sources.dropsUrl,
ebay: upstreams.ebayConfigured() ? 'configured' : 'off (no key pair)',
ebay: upstreams.ebayConfigured() ? 'configured' : 'off (no key pair)',
- watch: config.watch.token ? 'configured' : 'off (no service token)',
+ watch: 'the person\'s own Network token',
},
},
});
});
});
});
@@ -133,7 +133,7 @@ function createPageRoutes(ctx) {
@@ -133,7 +133,7 @@ function createPageRoutes(ctx) {
condition: { op: 'lt', value: threshold },
condition: { op: 'lt', value: threshold },
action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
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({
await catalogue.createWatch({
subject: req.viewer.subject, kind: 'price', modelId: model.id, slug: model.slug, size, condition,
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,
threshold, currency: 'USD', remoteId: out.ok && out.watch ? out.watch.id : null,
@@ -164,7 +164,7 @@ function createPageRoutes(ctx) {
@@ -164,7 +164,7 @@ function createPageRoutes(ctx) {
condition: { op: 'changed' },
condition: { op: 'changed' },
action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
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({
await catalogue.createWatch({
subject: req.viewer.subject, kind: 'link', sourceUrl: checkedUrl.url,
subject: req.viewer.subject, kind: 'link', sourceUrl: checkedUrl.url,
remoteId: out.ok && out.watch ? out.watch.id : null,
remoteId: out.ok && out.watch ? out.watch.id : null,
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
* products. No key: these are the same reads any person can make.
* 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.
* 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).
* 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).
* 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.
* 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,
@@ -164,20 +164,19 @@ function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console,
// ── OpenVibe.Watch ──────────────────────────────────────
// ── 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`;
const url = `${config.watch.internalUrl.replace(/\/+$/, '')}/api/v1/watches`;
try {
try {
const res = await request(url, {
const res = await request(url, {
method: 'POST',
method: 'POST',
headers: {
headers: {
'content-type': 'application/json',
'content-type': 'application/json',
- authorization: `Bearer ${config.watch.token}`,
- 'x-ov-subject': String(subject || ''),
+ authorization: `Bearer ${token}`,
},
},
body: JSON.stringify(body),
body: JSON.stringify(body),
json: true,
json: true,
@@ -82,7 +82,8 @@ const { assert, equal } = require('./helpers/check');
@@ -82,7 +82,8 @@ const { assert, equal } = require('./helpers/check');
});
});
await check('POST /api/v1/me/watches: a price watch through OpenVibe.Watch', async () => {
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);
equal(res.status, 201);
const b = res.json();
const b = res.json();
equal(b.watch.kind, 'price');
equal(b.watch.kind, 'price');
@@ -94,11 +95,21 @@ const { assert, equal } = require('./helpers/check');
@@ -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');
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.op, 'lt');
equal(sent.body.condition.value, 90);
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);
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 () => {
await check('POST /shoe/:slug/watch: the page form creates the same price watch', async () => {
const before = t.upstreams.state.watchRequests.length;
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' } });
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');
@@ -108,6 +119,7 @@ const { assert, equal } = require('./helpers/check');
const sent = t.upstreams.state.watchRequests[before];
const sent = t.upstreams.state.watchRequests[before];
equal(sent.body.condition.value, 75);
equal(sent.body.condition.value, 75);
equal(sent.body.source.kind, 'api');
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 () => {
await check('POST /api/v1/me/track: a pasted link becomes a Watch', async () => {
@@ -140,7 +152,7 @@ const { assert, equal } = require('./helpers/check');
@@ -140,7 +152,7 @@ const { assert, equal } = require('./helpers/check');
await check('GET /api/v1/status: the jobs, counts and what is configured', async () => {
await check('GET /api/v1/status: the jobs, counts and what is configured', async () => {
const b = (await t.get('/api/v1/status')).json();
const b = (await t.get('/api/v1/status')).json();
equal(b.counts.models, 4);
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');
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 === 'offers'), 'the offers job is listed');
assert(b.jobs.some((j) => j.job === 'drops'), 'the drops job is listed');
assert(b.jobs.some((j) => j.job === 'drops'), 'the drops job is listed');
@@ -40,7 +40,7 @@ async function boot(opts = {}) {
@@ -40,7 +40,7 @@ async function boot(opts = {}) {
SHOES_DROPS_URL: `${upstreams.url}/launch`,
SHOES_DROPS_URL: `${upstreams.url}/launch`,
SHOES_DROPS_SOURCE_NAME: 'Nike SNKRS launch calendar',
SHOES_DROPS_SOURCE_NAME: 'Nike SNKRS launch calendar',
SHOES_DROP_FEEDS: JSON.stringify([{ url: `${upstreams.url}/feed`, name: 'Example releases', type: 'rss' }]),
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_TODAY_INTERNAL_URL: upstreams.url,
SHOES_EBAY_CLIENT_ID: 'ebay-id', SHOES_EBAY_CLIENT_SECRET: 'ebay-secret', SHOES_EBAY_API_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',
SHOES_UPSTREAM_TIMEOUT_MS: '4000',